chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,280 @@
|
||||
// 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user