-
-
Notifications
You must be signed in to change notification settings - Fork 1.4k
Expand file tree
/
Copy pathchat-snapshot-integration.test.ts
More file actions
234 lines (199 loc) · 9.63 KB
/
Copy pathchat-snapshot-integration.test.ts
File metadata and controls
234 lines (199 loc) · 9.63 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
// 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");
});
});