Files
2026-07-13 13:32:57 +08:00

281 lines
10 KiB
TypeScript

// Import the test entry point first so the resource catalog is installed —
// not strictly required for these helper-level tests, but keeps parity with
// the rest of the test suite and removes a potential foot-gun if a future
// edit introduces a chat.agent({...}) at module scope.
import "../src/v3/test/index.js";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { apiClientManager } from "@trigger.dev/core/v3";
import {
__readChatSnapshotProductionPathForTests as readChatSnapshot,
__writeChatSnapshotProductionPathForTests as writeChatSnapshot,
type ChatSnapshotV1,
} from "../src/v3/ai.js";
// ── Helpers ────────────────────────────────────────────────────────────
/**
* Build a minimal ChatSnapshotV1 with `count` user messages. Used as the
* production-path test payload — `messages` is the only field the runtime
* inspects beyond `version`.
*/
function buildSnapshot(count = 1): ChatSnapshotV1 {
return {
version: 1,
savedAt: 1_000_000,
messages: Array.from({ length: count }, (_, i) => ({
id: `m${i}`,
role: "user" as const,
parts: [{ type: "text" as const, text: `hello ${i}` }],
})),
lastOutEventId: "evt-42",
};
}
/**
* Stub `apiClientManager.clientOrThrow()` so the helpers see a fake API
* client whose `getChatSnapshotUrl` / `createChatSnapshotUploadUrl` resolve
* with the presigned URLs the test wants. Returns spies for assertion.
*/
function stubApiClient(opts: {
getChatSnapshotUrl?: (sessionId: string) => Promise<{ presignedUrl: string }>;
createChatSnapshotUploadUrl?: (sessionId: string) => Promise<{ presignedUrl: string }>;
}) {
const getChatSnapshotUrl = vi.fn(
opts.getChatSnapshotUrl ??
(async (_sessionId: string) => ({ presignedUrl: "https://example.invalid/get" }))
);
const createChatSnapshotUploadUrl = vi.fn(
opts.createChatSnapshotUploadUrl ??
(async (_sessionId: string) => ({ presignedUrl: "https://example.invalid/put" }))
);
const fakeClient = {
getChatSnapshotUrl,
createChatSnapshotUploadUrl,
};
vi.spyOn(apiClientManager, "clientOrThrow").mockReturnValue(fakeClient as never);
return { getChatSnapshotUrl, createChatSnapshotUploadUrl };
}
/**
* Stub global `fetch` so the helpers see whatever Response (or throw) the
* test wants. Returns a spy keyed on the URL passed.
*/
function stubFetch(impl: (url: string, init?: RequestInit) => Promise<Response> | Response) {
const spy = vi.fn(impl);
vi.stubGlobal("fetch", spy);
return spy;
}
// ── Tests ──────────────────────────────────────────────────────────────
describe("chat snapshot helpers", () => {
// Suppress the runtime's `logger.warn` calls — they pollute output but
// don't change test outcomes. Restored in afterEach.
let warnSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
warnSpy.mockRestore();
});
describe("readChatSnapshot", () => {
it("returns the snapshot on a successful GET", async () => {
const { getChatSnapshotUrl } = stubApiClient({});
const snapshot = buildSnapshot(2);
stubFetch(
async () =>
new Response(JSON.stringify(snapshot), {
status: 200,
headers: { "content-type": "application/json" },
})
);
const result = await readChatSnapshot("session-1");
expect(getChatSnapshotUrl).toHaveBeenCalledWith("session-1");
expect(result).toMatchObject({
version: 1,
messages: snapshot.messages,
lastOutEventId: "evt-42",
});
});
it("returns undefined on 404 (fresh session, no snapshot yet)", async () => {
stubApiClient({});
stubFetch(async () => new Response("Not Found", { status: 404 }));
const result = await readChatSnapshot("missing-session");
expect(result).toBeUndefined();
});
it("returns undefined on non-404 non-OK (e.g. 500)", async () => {
stubApiClient({});
stubFetch(async () => new Response("Internal Error", { status: 500 }));
const result = await readChatSnapshot("flaky-session");
expect(result).toBeUndefined();
});
it("returns undefined when the response body is malformed JSON", async () => {
stubApiClient({});
stubFetch(
async () =>
new Response("not-json-{[", {
status: 200,
headers: { "content-type": "application/json" },
})
);
const result = await readChatSnapshot("malformed-session");
expect(result).toBeUndefined();
});
it("returns undefined on version mismatch (forward-compat)", async () => {
stubApiClient({});
// Future format the current runtime can't decode — runtime ignores it.
const futureSnapshot = {
version: 99,
savedAt: Date.now(),
messages: [],
};
stubFetch(
async () =>
new Response(JSON.stringify(futureSnapshot), {
status: 200,
headers: { "content-type": "application/json" },
})
);
const result = await readChatSnapshot("v99-session");
expect(result).toBeUndefined();
});
it("returns undefined when `messages` field is missing or wrong type", async () => {
stubApiClient({});
stubFetch(
async () =>
new Response(JSON.stringify({ version: 1, savedAt: 1, messages: "not-an-array" }), {
status: 200,
})
);
const result = await readChatSnapshot("bad-shape-session");
expect(result).toBeUndefined();
});
it("returns undefined when fetch throws (network error)", async () => {
stubApiClient({});
stubFetch(async () => {
throw new Error("ECONNREFUSED");
});
const result = await readChatSnapshot("offline-session");
expect(result).toBeUndefined();
});
it("returns undefined when presign call fails", async () => {
stubApiClient({
getChatSnapshotUrl: async () => {
throw new Error("presign denied");
},
});
// No fetch should fire — presign failed.
const fetchSpy = stubFetch(async () => new Response("nope", { status: 500 }));
const result = await readChatSnapshot("denied-session");
expect(result).toBeUndefined();
expect(fetchSpy).not.toHaveBeenCalled();
});
it("returns undefined when the response is not an object", async () => {
stubApiClient({});
stubFetch(async () => new Response(JSON.stringify("just-a-string"), { status: 200 }));
const result = await readChatSnapshot("string-response");
expect(result).toBeUndefined();
});
});
describe("writeChatSnapshot", () => {
it("PUTs the snapshot JSON to the presigned URL", async () => {
const { createChatSnapshotUploadUrl } = stubApiClient({});
const fetchSpy = stubFetch(async () => new Response(null, { status: 200 }));
const snapshot = buildSnapshot(3);
await writeChatSnapshot("session-2", snapshot);
expect(createChatSnapshotUploadUrl).toHaveBeenCalledWith("session-2");
expect(fetchSpy).toHaveBeenCalledOnce();
const [url, init] = fetchSpy.mock.calls[0]!;
expect(url).toBe("https://example.invalid/put");
expect((init as RequestInit).method).toBe("PUT");
expect((init as RequestInit).headers).toMatchObject({
"content-type": "application/json",
});
// Body is the JSON-stringified snapshot — round-trip to confirm.
const sentBody = JSON.parse((init as RequestInit).body as string);
expect(sentBody).toEqual(snapshot);
});
it("returns without throwing on a non-OK PUT response (warns)", async () => {
stubApiClient({});
stubFetch(async () => new Response("forbidden", { status: 403 }));
await expect(
writeChatSnapshot("forbidden-session", buildSnapshot())
).resolves.toBeUndefined();
});
it("returns without throwing on a fetch network error (warns)", async () => {
stubApiClient({});
stubFetch(async () => {
throw new Error("ETIMEDOUT");
});
await expect(writeChatSnapshot("timeout-session", buildSnapshot())).resolves.toBeUndefined();
});
it("returns without throwing when presign fails (warns)", async () => {
stubApiClient({
createChatSnapshotUploadUrl: async () => {
throw new Error("presign denied");
},
});
const fetchSpy = stubFetch(async () => new Response(null, { status: 200 }));
await expect(writeChatSnapshot("denied-session", buildSnapshot())).resolves.toBeUndefined();
// Presign failed → no PUT attempted.
expect(fetchSpy).not.toHaveBeenCalled();
});
it("addresses reads and writes by the same sessionId", async () => {
// Round-trip check: both presign methods receive the same sessionId.
// The canonical key (`sessions/{id}/snapshot.json`) lives server-side
// now, so the SDK has no key string to compare — sessionId equality
// is the SDK-visible invariant.
const { getChatSnapshotUrl } = stubApiClient({
getChatSnapshotUrl: async () => ({ presignedUrl: "https://example.invalid/get" }),
});
stubFetch(async () => new Response(null, { status: 404 }));
await readChatSnapshot("round-trip-session");
const [readArg] = getChatSnapshotUrl.mock.calls[0]!;
const { createChatSnapshotUploadUrl } = stubApiClient({
createChatSnapshotUploadUrl: async () => ({ presignedUrl: "https://example.invalid/put" }),
});
stubFetch(async () => new Response(null, { status: 200 }));
await writeChatSnapshot("round-trip-session", buildSnapshot());
const [writeArg] = createChatSnapshotUploadUrl.mock.calls[0]!;
expect(readArg).toBe(writeArg);
expect(readArg).toBe("round-trip-session");
});
});
});