1import { ApprovalRequestId, EnvironmentId, ThreadId } from "@t3tools/contracts";
2import { describe, expect, it, vi } from "vite-plus/test";
3import {
4 questionAttachmentDraftKey,
5 questionAttachmentDraftPrefix,
6} from "../state/question-attachments";
7vi.mock("../state/atom-registry", () => ({ appAtomRegistry: {} }));
8
9import {
10 composerAttachmentUploadBlockReason,
11 composerAttachmentsStillUploading,
12 composerAttachmentUploadKey,
13 composerDraftEnvironmentId,
14 createComposerAttachmentUploadQueue,
15 type ComposerAttachmentUploadRequest,
16 type ComposerAttachmentUploadState,
17} from "./composerAttachmentUploadQueue";
18
19const environmentId = EnvironmentId.make("environment-1");
20function request(id: string, environment = environmentId): ComposerAttachmentUploadRequest {
21 return {
22 environmentId: environment,
23 attachment: {
24 id,
25 type: "file",
26 name: `${id}.pdf`,
27 mimeType: "application/pdf",
28 sizeBytes: 42,
29 fileUri: `file:///documents/${id}.pdf`,
30 },
31 };
32}
33
34describe("composer attachment upload queue", () => {
35 it("bounds concurrency, deduplicates updates, and drains all attachments", async () => {
36 const gates = new Map<string, ReturnType<typeof Promise.withResolvers<boolean>>>();
37 const fourthStarted = Promise.withResolvers<void>();
38 const firstThreeStarted = Promise.withResolvers<void>();
39 let active = 0;
40 let maximum = 0;
41 const upload = vi.fn(async (input: ComposerAttachmentUploadRequest) => {
42 active += 1;
43 maximum = Math.max(maximum, active);
44 const gate = Promise.withResolvers<boolean>();
45 gates.set(input.attachment.id, gate);
46 if (gates.size === 3) firstThreeStarted.resolve();
47 if (gates.size === 4) fourthStarted.resolve();
48 try {
49 return await gate.promise;
50 } finally {
51 active -= 1;
52 }
53 });
54 const queue = createComposerAttachmentUploadQueue({ upload, onChange: () => {} });
55 const requests = [request("one"), request("two"), request("three"), request("four")];
56 queue.sync(requests);
57 queue.sync(requests);
58 await firstThreeStarted.promise;
59 expect(upload).toHaveBeenCalledTimes(3);
60 gates.get("one")!.resolve(true);
61 await fourthStarted.promise;
62 for (const gate of gates.values()) gate.resolve(true);
63 await queue.settled();
64 queue.sync(requests);
65 await queue.settled();
66 expect(maximum).toBe(3);
67 expect(upload).toHaveBeenCalledTimes(4);
68 queue.dispose();
69 });
70
71 it("cancels on disconnect and resumes from the same local draft on reconnect", async () => {
72 const started = Promise.withResolvers<void>();
73 let states: Readonly<Record<string, ComposerAttachmentUploadState>> = {};
74 let signal: AbortSignal | undefined;
75 const upload = vi.fn(
76 async (_request: ComposerAttachmentUploadRequest, currentSignal: AbortSignal) => {
77 signal = currentSignal;
78 started.resolve();
79 return new Promise<boolean>((resolve) =>
80 currentSignal.addEventListener("abort", () => resolve(false), { once: true }),
81 );
82 },
83 );
84 const queue = createComposerAttachmentUploadQueue({
85 upload,
86 onChange: (next) => {
87 states = next;
88 },
89 });
90 const local = request("offline-draft");
91 queue.sync([local]);
92 await started.promise;
93 queue.sync([]);
94 await queue.settled();
95 expect(signal?.aborted).toBe(true);
96 expect(states).toEqual({});
97 upload.mockResolvedValueOnce(true);
98 queue.sync([local]);
99 await queue.settled();
100 expect(upload.mock.calls[1]?.[0]).toBe(local);
101 expect(states[composerAttachmentUploadKey(environmentId, local.attachment.id)]).toEqual({
102 status: "ready",
103 });
104 expect(local.attachment).toMatchObject({ fileUri: "file:///documents/offline-draft.pdf" });
105 queue.dispose();
106 });
107
108 it("retries a failed attachment when the worker restores requests after reconnect", async () => {
109 let states: Readonly<Record<string, ComposerAttachmentUploadState>> = {};
110 const upload = vi.fn().mockRejectedValueOnce(new Error("Disconnected")).mockResolvedValue(true);
111 const queue = createComposerAttachmentUploadQueue({
112 upload,
113 onChange: (next) => {
114 states = next;
115 },
116 });
117 const local = request("failed-upload");
118 const key = composerAttachmentUploadKey(environmentId, local.attachment.id);
119 queue.sync([local]);
120 await queue.settled();
121 expect(states[key]?.status).toBe("failed");
122 queue.sync([local]);
123 await queue.settled();
124 expect(upload).toHaveBeenCalledTimes(1);
125 queue.sync([]);
126 queue.sync([local]);
127 await queue.settled();
128 expect(upload).toHaveBeenCalledTimes(2);
129 expect(states[key]?.status).toBe("ready");
130 queue.dispose();
131 });
132
133 it("ignores a late completion after removal or environment switch", async () => {
134 const gate = Promise.withResolvers<boolean>();
135 const started = Promise.withResolvers<void>();
136 let states: Readonly<Record<string, ComposerAttachmentUploadState>> = {};
137 const upload = vi.fn(async () => {
138 started.resolve();
139 return gate.promise;
140 });
141 const queue = createComposerAttachmentUploadQueue({
142 upload,
143 onChange: (next) => {
144 states = next;
145 },
146 });
147 queue.sync([request("photo")]);
148 await started.promise;
149 upload.mockResolvedValueOnce(true);
150 const other = EnvironmentId.make("environment-2");
151 queue.sync([request("photo", other)]);
152 gate.resolve(true);
153 await queue.settled();
154 expect(states).toEqual({ [composerAttachmentUploadKey(other, "photo")]: { status: "ready" } });
155 queue.sync([]);
156 expect(states).toEqual({});
157 queue.dispose();
158 });
159
160 it("restarts a re-added attachment after its aborted transfer finishes settling", async () => {
161 const firstStarted = Promise.withResolvers<void>();
162 const firstSettled = Promise.withResolvers<boolean>();
163 const secondStarted = Promise.withResolvers<void>();
164 const secondSettled = Promise.withResolvers<boolean>();
165 let states: Readonly<Record<string, ComposerAttachmentUploadState>> = {};
166 let firstSignal: AbortSignal | undefined;
167 const upload = vi.fn(async (_request: ComposerAttachmentUploadRequest, signal: AbortSignal) => {
168 if (!firstSignal) {
169 firstSignal = signal;
170 firstStarted.resolve();
171 return firstSettled.promise;
172 }
173 secondStarted.resolve();
174 return secondSettled.promise;
175 });
176 const queue = createComposerAttachmentUploadQueue({
177 upload,
178 onChange: (next) => {
179 states = next;
180 },
181 });
182 const local = request("re-added");
183 queue.sync([local]);
184 await firstStarted.promise;
185 queue.sync([]);
186 queue.sync([local]);
187 expect(firstSignal?.aborted).toBe(true);
188 expect(upload).toHaveBeenCalledOnce();
189 firstSettled.resolve(false);
190 await secondStarted.promise;
191 expect(upload).toHaveBeenCalledTimes(2);
192 secondSettled.resolve(true);
193 await queue.settled();
194 expect(states[composerAttachmentUploadKey(environmentId, local.attachment.id)]).toEqual({
195 status: "ready",
196 });
197 queue.dispose();
198 });
199
200 it("keeps failures stable until retry and reports bounded progress", async () => {
201 let states: Readonly<Record<string, ComposerAttachmentUploadState>> = {};
202 const progress: number[] = [];
203 const upload = vi.fn(
204 async (
205 _request: ComposerAttachmentUploadRequest,
206 _signal: AbortSignal,
207 report: (value: number) => void,
208 ): Promise<boolean> => {
209 report(0.12);
210 report(0.13);
211 report(1.1);
212 throw new Error("Server unavailable");
213 },
214 );
215 const queue = createComposerAttachmentUploadQueue({
216 upload,
217 onChange: (next) => {
218 states = next;
219 const state = next[composerAttachmentUploadKey(environmentId, "file")];
220 if (state?.status === "uploading") progress.push(state.progress);
221 },
222 });
223 queue.sync([request("file")]);
224 await queue.settled();
225 queue.sync([request("file")]);
226 expect(upload).toHaveBeenCalledOnce();
227 expect(states[composerAttachmentUploadKey(environmentId, "file")]).toEqual({
228 status: "failed",
229 reason: "Server unavailable",
230 });
231 expect(progress).toEqual([0, 0.1, 1]);
232 upload.mockImplementationOnce(async () => true);
233 queue.retry(environmentId, "file");
234 await queue.settled();
235 expect(states[composerAttachmentUploadKey(environmentId, "file")]).toEqual({ status: "ready" });
236 queue.dispose();
237 });
238
239 it("does not spin when an upload's draft was abandoned before persistence", async () => {
240 const upload = vi.fn(async () => false);
241 const queue = createComposerAttachmentUploadQueue({ upload, onChange: () => {} });
242 queue.sync([request("discarded")]);
243 await queue.settled();
244 expect(upload).toHaveBeenCalledOnce();
245 queue.dispose();
246 });
247});
248
249describe("draft upload scope and offline submission", () => {
250 it("uploads question drafts in their environment without matching other thread prefixes", () => {
251 const requestId = ApprovalRequestId.make("request:1");
252 for (const environment of [environmentId, EnvironmentId.make("remote:server")]) {
253 const threadId = ThreadId.make("thread:1");
254 const prefix = questionAttachmentDraftPrefix(environment, threadId);
255 const key = questionAttachmentDraftKey(environment, threadId, requestId, "question:1");
256 expect(composerDraftEnvironmentId(key, [])).toBe(environment);
257 expect(key.startsWith(prefix)).toBe(true);
258 for (const suffix of ["-extra", ":extra", "/extra", "%extra"]) {
259 const otherKey = questionAttachmentDraftKey(
260 environment,
261 ThreadId.make(`${threadId}${suffix}`),
262 requestId,
263 "question:1",
264 );
265 expect(otherKey.startsWith(prefix)).toBe(false);
266 expect(composerDraftEnvironmentId(otherKey, [])).toBe(environment);
267 }
268 }
269 });
270 it("resolves thread, new-task, and queued-task drafts without crossing environments", () => {
271 expect(composerDraftEnvironmentId("environment-1:thread", [])).toBe(environmentId);
272 expect(composerDraftEnvironmentId("new-task:environment-1:project", [])).toBe(environmentId);
273 expect(
274 composerDraftEnvironmentId("pending-task:message", [{ messageId: "message", environmentId }]),
275 ).toBe(environmentId);
276 expect(composerDraftEnvironmentId("pending-task:missing", [])).toBeNull();
277 const colonEnvironment = EnvironmentId.make("a:vcs-status:b");
278 expect(composerDraftEnvironmentId(`${colonEnvironment}:thread`, [])).toBe(colonEnvironment);
279 expect(composerDraftEnvironmentId(`new-task:${colonEnvironment}:project`, [])).toBe(
280 colonEnvironment,
281 );
282 });
283
284 it("reads an id-keyed new-task draft's environment from its project stamp", () => {
285 const stamped = {
286 project: {
287 environmentId,
288 projectId: "project" as never,
289 createdAt: "2026-09-05T00:00:00.000Z",
290 },
291 };
292 expect(composerDraftEnvironmentId("new-task:abc123-def456", [], stamped)).toBe(environmentId);
293 // An id-keyed draft that lost its stamp belongs to nobody: uploads must
294 // not start and sign-out must not sweep it into some other environment.
295 expect(composerDraftEnvironmentId("new-task:abc123-def456", [])).toBeNull();
296 // The stamp wins over a legacy-looking key when both are present.
297 expect(composerDraftEnvironmentId("new-task:environment-2:project", [], stamped)).toBe(
298 environmentId,
299 );
300 });
301
302 it("only a failed upload blocks sending; an in-flight one queues instead", () => {
303 const key = composerAttachmentUploadKey(environmentId, "file");
304 const input = {
305 environmentId,
306 attachments: [request("file").attachment],
307 connected: true,
308 serverConfig: {
309 environment: {
310 capabilities: { attachmentUploads: true, fileAttachments: { maxUploadBytes: 1024 } },
311 },
312 },
313 states: {},
314 };
315 // Not started yet and mid-transfer both let the send through as a queued
316 // message; the outbox drain finishes (or redoes) the upload.
317 expect(composerAttachmentUploadBlockReason(input)).toBeNull();
318 expect(composerAttachmentsStillUploading(input)).toBe(true);
319 expect(
320 composerAttachmentsStillUploading({
321 ...input,
322 states: { [key]: { status: "uploading", progress: 0.6 } },
323 }),
324 ).toBe(true);
325 expect(composerAttachmentUploadBlockReason({ ...input, connected: false })).toBeNull();
326 expect(
327 composerAttachmentUploadBlockReason({
328 ...input,
329 states: { [key]: { status: "failed", reason: "Offline" } },
330 }),
331 ).toBe("Retry or remove the failed attachment");
332 expect(
333 composerAttachmentsStillUploading({
334 ...input,
335 states: { [key]: { status: "failed", reason: "Offline" } },
336 }),
337 ).toBe(false);
338 expect(
339 composerAttachmentUploadBlockReason({ ...input, states: { [key]: { status: "ready" } } }),
340 ).toBeNull();
341 expect(
342 composerAttachmentsStillUploading({ ...input, states: { [key]: { status: "ready" } } }),
343 ).toBe(false);
344 // Attachments the environment cannot accept never count as uploading.
345 expect(composerAttachmentsStillUploading({ ...input, serverConfig: null })).toBe(false);
346 });
347});