Files
triggerdotdev--trigger.dev/apps/webapp/test/chat-snapshot-integration.test.ts
2026-07-13 13:32:57 +08:00

247 lines
9.8 KiB
TypeScript

// Plan F.3: integration test that round-trips a `ChatSnapshotV1` blob
// through the SDK's snapshot helpers + a real MinIO backing store. Mirrors
// the testcontainer pattern from `objectStore.test.ts`.
//
// What this verifies end-to-end:
// - SDK's `writeChatSnapshot` calls `apiClient.createUploadPayloadUrl`
// to mint a presigned PUT, then PUTs JSON to it.
// - SDK's `readChatSnapshot` calls `apiClient.getPayloadUrl` to mint a
// presigned GET, then fetches and parses.
// - The webapp's `generatePresignedUrl` produces URLs MinIO accepts.
// - The blob round-trips with `version: 1` shape preserved.
// - 404 (no snapshot for a fresh session) returns `undefined`, not an
// error.
//
// This is the integration safety net behind the unit tests in
// `packages/trigger-sdk/test/chat-snapshot.test.ts` — those tests mock
// `fetch`; this one drives a real S3-compatible backend.
import { postgresAndMinioTest } from "@internal/testcontainers";
import { apiClientManager } from "@trigger.dev/core/v3";
import {
__readChatSnapshotProductionPathForTests as readChatSnapshot,
__writeChatSnapshotProductionPathForTests as writeChatSnapshot,
type ChatSnapshotV1,
} from "@trigger.dev/sdk/ai";
import type { UIMessage } from "ai";
import { afterEach, describe, expect, vi } from "vitest";
import { env } from "~/env.server";
import { chatSnapshotStoragePathForSession } from "~/services/realtime/chatSnapshot.server";
import { generatePresignedUrl } from "~/v3/objectStore.server";
vi.setConfig({ testTimeout: 60_000 });
// ── Helpers ────────────────────────────────────────────────────────────
function makeSnapshot(
opts: { messages?: UIMessage[]; lastOutEventId?: string } = {}
): ChatSnapshotV1 {
return {
version: 1,
savedAt: 1_700_000_000_000,
messages: opts.messages ?? [
{
id: "u-1",
role: "user",
parts: [{ type: "text", text: "hello" }],
},
{
id: "a-1",
role: "assistant",
parts: [{ type: "text", text: "world" }],
},
],
lastOutEventId: opts.lastOutEventId ?? "evt-42",
};
}
/**
* Stub `apiClientManager.clientOrThrow()` so the SDK helpers see a fake
* api client. Mirrors the snapshot-url route: derive the canonical
* `sessions/{id}/snapshot.json` key (with optional default-protocol
* prefix) and sign it via `generatePresignedUrl` against MinIO.
*/
function stubApiClient(opts: { projectRef: string; envSlug: string }) {
vi.spyOn(apiClientManager, "clientOrThrow").mockReturnValue({
async getChatSnapshotUrl(sessionId: string) {
const key = chatSnapshotStoragePathForSession(sessionId);
const result = await generatePresignedUrl(opts.projectRef, opts.envSlug, key, "GET");
if (!result.success) throw new Error(result.error);
return { presignedUrl: result.url };
},
async createChatSnapshotUploadUrl(sessionId: string) {
const key = chatSnapshotStoragePathForSession(sessionId);
const result = await generatePresignedUrl(opts.projectRef, opts.envSlug, key, "PUT");
if (!result.success) throw new Error(result.error);
return { presignedUrl: result.url };
},
} as never);
}
// Suppress noisy warnings from logger.warn during error-path tests.
let warnSpy: ReturnType<typeof vi.spyOn>;
afterEach(() => {
vi.restoreAllMocks();
warnSpy?.mockRestore();
});
// ── Tests ──────────────────────────────────────────────────────────────
describe("chat snapshot integration (MinIO + SDK helpers)", () => {
postgresAndMinioTest("round-trips a snapshot through real MinIO", async ({ minioConfig }) => {
env.OBJECT_STORE_BASE_URL = minioConfig.baseUrl;
env.OBJECT_STORE_ACCESS_KEY_ID = minioConfig.accessKeyId;
env.OBJECT_STORE_SECRET_ACCESS_KEY = minioConfig.secretAccessKey;
env.OBJECT_STORE_REGION = minioConfig.region;
env.OBJECT_STORE_DEFAULT_PROTOCOL = undefined;
stubApiClient({ projectRef: "proj_snap_rt", envSlug: "dev" });
const sessionId = "sess_round_trip_1";
const snapshot = makeSnapshot();
// Write through the SDK helper — should land in MinIO at
// `packets/proj_snap_rt/dev/sessions/sess_round_trip_1/snapshot.json`.
await writeChatSnapshot(sessionId, snapshot);
// Read back through the SDK helper — should reconstruct the original.
const result = await readChatSnapshot(sessionId);
expect(result).toEqual(snapshot);
});
postgresAndMinioTest(
"returns undefined for a fresh session with no snapshot",
async ({ minioConfig }) => {
env.OBJECT_STORE_BASE_URL = minioConfig.baseUrl;
env.OBJECT_STORE_ACCESS_KEY_ID = minioConfig.accessKeyId;
env.OBJECT_STORE_SECRET_ACCESS_KEY = minioConfig.secretAccessKey;
env.OBJECT_STORE_REGION = minioConfig.region;
env.OBJECT_STORE_DEFAULT_PROTOCOL = undefined;
stubApiClient({ projectRef: "proj_snap_404", envSlug: "dev" });
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
// Session never had a snapshot written — read returns undefined.
const result = await readChatSnapshot("sess_never_existed");
expect(result).toBeUndefined();
}
);
postgresAndMinioTest(
"overwrites a prior snapshot in place (single-writer)",
async ({ minioConfig }) => {
// The runtime guarantees one attempt alive at a time, and
// `writeChatSnapshot` runs awaited after `onTurnComplete`. Verify
// that a second write to the same key replaces the first cleanly —
// the read-after-write reflects the latest blob.
env.OBJECT_STORE_BASE_URL = minioConfig.baseUrl;
env.OBJECT_STORE_ACCESS_KEY_ID = minioConfig.accessKeyId;
env.OBJECT_STORE_SECRET_ACCESS_KEY = minioConfig.secretAccessKey;
env.OBJECT_STORE_REGION = minioConfig.region;
env.OBJECT_STORE_DEFAULT_PROTOCOL = undefined;
stubApiClient({ projectRef: "proj_snap_overwrite", envSlug: "dev" });
const sessionId = "sess_overwrite";
const turn1 = makeSnapshot({
messages: [{ id: "u-1", role: "user", parts: [{ type: "text", text: "first" }] }],
lastOutEventId: "evt-turn1",
});
const turn2 = makeSnapshot({
messages: [
{ id: "u-1", role: "user", parts: [{ type: "text", text: "first" }] },
{ id: "a-1", role: "assistant", parts: [{ type: "text", text: "reply-1" }] },
{ id: "u-2", role: "user", parts: [{ type: "text", text: "second" }] },
{ id: "a-2", role: "assistant", parts: [{ type: "text", text: "reply-2" }] },
],
lastOutEventId: "evt-turn2",
});
await writeChatSnapshot(sessionId, turn1);
await writeChatSnapshot(sessionId, turn2);
const result = await readChatSnapshot(sessionId);
expect(result).toEqual(turn2);
expect(result?.messages).toHaveLength(4);
expect(result?.lastOutEventId).toBe("evt-turn2");
}
);
postgresAndMinioTest(
"isolates snapshots by sessionId (no cross-talk)",
async ({ minioConfig }) => {
env.OBJECT_STORE_BASE_URL = minioConfig.baseUrl;
env.OBJECT_STORE_ACCESS_KEY_ID = minioConfig.accessKeyId;
env.OBJECT_STORE_SECRET_ACCESS_KEY = minioConfig.secretAccessKey;
env.OBJECT_STORE_REGION = minioConfig.region;
env.OBJECT_STORE_DEFAULT_PROTOCOL = undefined;
stubApiClient({ projectRef: "proj_snap_iso", envSlug: "dev" });
const sessA = "sess_iso_A";
const sessB = "sess_iso_B";
const snapA = makeSnapshot({ lastOutEventId: "evt-A" });
const snapB = makeSnapshot({ lastOutEventId: "evt-B" });
await writeChatSnapshot(sessA, snapA);
await writeChatSnapshot(sessB, snapB);
const readA = await readChatSnapshot(sessA);
const readB = await readChatSnapshot(sessB);
expect(readA?.lastOutEventId).toBe("evt-A");
expect(readB?.lastOutEventId).toBe("evt-B");
// Distinct objects — modifying one shouldn't affect the other.
expect(readA?.lastOutEventId).not.toBe(readB?.lastOutEventId);
}
);
postgresAndMinioTest(
"handles snapshots with large message lists (~50 messages)",
async ({ minioConfig }) => {
// Stress test: a 50-turn chat snapshot. Plan F.4 mentions the
// pre-change baseline grew past 512 KiB around turn 10-30 with tool
// use; the post-slim wire keeps wire payloads small but the snapshot
// itself can still get large. Verify the helpers handle a realistic
// payload size.
env.OBJECT_STORE_BASE_URL = minioConfig.baseUrl;
env.OBJECT_STORE_ACCESS_KEY_ID = minioConfig.accessKeyId;
env.OBJECT_STORE_SECRET_ACCESS_KEY = minioConfig.secretAccessKey;
env.OBJECT_STORE_REGION = minioConfig.region;
env.OBJECT_STORE_DEFAULT_PROTOCOL = undefined;
stubApiClient({ projectRef: "proj_snap_big", envSlug: "dev" });
const messages: UIMessage[] = [];
for (let i = 0; i < 50; i++) {
messages.push({
id: `u-${i}`,
role: "user",
parts: [{ type: "text", text: `user message ${i}: ${"x".repeat(200)}` }],
});
messages.push({
id: `a-${i}`,
role: "assistant",
parts: [{ type: "text", text: `assistant reply ${i}: ${"y".repeat(500)}` }],
});
}
const snapshot = makeSnapshot({ messages, lastOutEventId: "evt-50" });
await writeChatSnapshot("sess_big_chat", snapshot);
const result = await readChatSnapshot("sess_big_chat");
expect(result).toBeDefined();
expect(result!.messages).toHaveLength(100);
expect(result!.lastOutEventId).toBe("evt-50");
// Spot-check ordering integrity — the messages array round-tripped
// in the same order.
expect(result!.messages[0]!.id).toBe("u-0");
expect(result!.messages[99]!.id).toBe("a-49");
}
);
});