fix(chat): lower unavailable media logs
This commit is contained in:
@@ -1,9 +1,14 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { Result } from "@/utils";
|
||||
|
||||
import { ChatMediaCacheCoordinator } from "../chat_media_cache_coordinator";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("ChatMediaCacheCoordinator identity isolation", () => {
|
||||
it("looks up media only in the current identity namespace", async () => {
|
||||
const getMedia = vi.fn(async () => Result.ok(null));
|
||||
@@ -27,6 +32,9 @@ describe("ChatMediaCacheCoordinator identity isolation", () => {
|
||||
});
|
||||
|
||||
it("does not fall back to an anonymous cache without an identity", async () => {
|
||||
const error = vi
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation(() => undefined);
|
||||
const getMedia = vi.fn(async () => Result.ok(null));
|
||||
const coordinator = new ChatMediaCacheCoordinator(
|
||||
{
|
||||
@@ -43,5 +51,42 @@ describe("ChatMediaCacheCoordinator identity isolation", () => {
|
||||
|
||||
expect(Result.isErr(result)).toBe(true);
|
||||
expect(getMedia).not.toHaveBeenCalled();
|
||||
expect(error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("ERROR [Result] Result.wrap caught exception"),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("logs expired remote media responses at info level", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () =>
|
||||
new Response(null, { status: 400, statusText: "Bad Request" }),
|
||||
),
|
||||
);
|
||||
const info = vi.spyOn(console, "info").mockImplementation(() => undefined);
|
||||
const error = vi
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation(() => undefined);
|
||||
const coordinator = new ChatMediaCacheCoordinator({
|
||||
getMedia: vi.fn(async () => Result.ok(null)),
|
||||
saveMedia: vi.fn(async () => Result.ok(undefined)),
|
||||
});
|
||||
|
||||
const result = await coordinator.cacheRemoteMedia(
|
||||
{
|
||||
messageId: "message-expired",
|
||||
kind: "image",
|
||||
remoteUrl: "https://media.example/expired.jpg",
|
||||
},
|
||||
"user:account-a",
|
||||
);
|
||||
|
||||
expect(Result.isErr(result)).toBe(true);
|
||||
expect(info).toHaveBeenCalledWith(
|
||||
expect.stringContaining("INFO [Result] Result.wrap caught exception"),
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(error).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,6 +27,23 @@ import {
|
||||
|
||||
const log = new Logger("DataRepositoriesChatMediaCacheCoordinator");
|
||||
|
||||
class ChatMediaDownloadError extends Error {
|
||||
override readonly cause?: unknown;
|
||||
readonly status?: number;
|
||||
|
||||
constructor(message: string, options?: { cause?: unknown; status?: number }) {
|
||||
super(message);
|
||||
this.name = "ChatMediaDownloadError";
|
||||
this.cause = options?.cause;
|
||||
this.status = options?.status;
|
||||
}
|
||||
}
|
||||
|
||||
function isChatMediaDownloadError(error: Error): boolean {
|
||||
if (error instanceof ChatMediaDownloadError) return true;
|
||||
return error.cause instanceof ChatMediaDownloadError;
|
||||
}
|
||||
|
||||
type ChatMediaStorage = Pick<
|
||||
LocalChatMediaStorage,
|
||||
"getMedia" | "saveMedia"
|
||||
@@ -55,10 +72,16 @@ export class ChatMediaCacheCoordinator {
|
||||
input: CacheRemoteChatMediaInput,
|
||||
identity?: string,
|
||||
): Promise<Result<LocalChatMediaRow>> {
|
||||
return Result.wrap(async () => {
|
||||
const ownerKey = await this._requireIdentity(identity);
|
||||
return this._cacheRemoteMediaOrThrow(input, ownerKey);
|
||||
});
|
||||
return Result.wrap(
|
||||
async () => {
|
||||
const ownerKey = await this._requireIdentity(identity);
|
||||
return this._cacheRemoteMediaOrThrow(input, ownerKey);
|
||||
},
|
||||
{
|
||||
errorLogLevel: (error) =>
|
||||
isChatMediaDownloadError(error) ? "info" : "error",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async prefetchMediaForMessages(
|
||||
@@ -93,12 +116,19 @@ export class ChatMediaCacheCoordinator {
|
||||
try {
|
||||
await this._cacheRemoteMediaOrThrow(target, ownerKey);
|
||||
} catch (error) {
|
||||
log.warn("[chat-media] prefetch failed", {
|
||||
const details = {
|
||||
messageId: target.messageId,
|
||||
kind: target.kind,
|
||||
remoteUrl: target.remoteUrl,
|
||||
error,
|
||||
});
|
||||
};
|
||||
if (error instanceof ChatMediaDownloadError) {
|
||||
log.info("[chat-media] remote media unavailable during prefetch", {
|
||||
...details,
|
||||
status: error.status,
|
||||
});
|
||||
} else {
|
||||
log.warn("[chat-media] prefetch failed", details);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -117,19 +147,34 @@ export class ChatMediaCacheCoordinator {
|
||||
if (Result.isErr(existingResult)) throw existingResult.error;
|
||||
if (existingResult.data) return existingResult.data;
|
||||
|
||||
const response = await fetch(input.remoteUrl, {
|
||||
method: "GET",
|
||||
cache: "force-cache",
|
||||
});
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(input.remoteUrl, {
|
||||
method: "GET",
|
||||
cache: "force-cache",
|
||||
});
|
||||
} catch (error) {
|
||||
throw new ChatMediaDownloadError("Media download request failed.", {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
throw new ChatMediaDownloadError(
|
||||
`Media download failed: ${response.status} ${response.statusText}`,
|
||||
{ status: response.status },
|
||||
);
|
||||
}
|
||||
|
||||
const bytes = await response.arrayBuffer();
|
||||
let bytes: ArrayBuffer;
|
||||
try {
|
||||
bytes = await response.arrayBuffer();
|
||||
} catch (error) {
|
||||
throw new ChatMediaDownloadError("Media download body could not be read.", {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
if (bytes.byteLength <= 0) {
|
||||
throw new Error("Media download returned empty bytes.");
|
||||
throw new ChatMediaDownloadError("Media download returned empty bytes.");
|
||||
}
|
||||
const mimeType =
|
||||
response.headers.get("content-type") ||
|
||||
|
||||
Reference in New Issue
Block a user