1import { EnvironmentId } from "@t3tools/contracts";
2import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
3import { Atom, AsyncResult } from "effect/reactivity";
4import { appAtomRegistry } from "../rpc/atomRegistry";
5
6import {
7 composerFileNeedsReattach,
8 DraftId,
9 useComposerDraftStore,
10 type ComposerFileAttachment,
11 type ComposerImageAttachment,
12} from "../composerDraftStore";
13
14const mocks = vi.hoisted(() => ({
15 connectionStateAtom: vi.fn(),
16 createAssetUrl: vi.fn(),
17 createUploadUrl: Symbol("create-upload-url"),
18 executeAtomQuery: vi.fn(),
19 removeUpload: Symbol("remove-upload"),
20 runAtomCommand: vi.fn(),
21 readEnvironmentScope: vi.fn(),
22 readPreparedConnection: vi.fn(),
23}));
24
25vi.mock("@t3tools/client-runtime/state/runtime", () => ({
26 executeAtomQuery: mocks.executeAtomQuery,
27 runAtomCommand: mocks.runAtomCommand,
28 squashAtomCommandFailure: (result: { readonly error: unknown }) => result.error,
29}));
30
31vi.mock("../rpc/atomRegistry", async () => {
32 const { AtomRegistry } = await import("effect/reactivity");
33 return { appAtomRegistry: AtomRegistry.make() };
34});
35
36vi.mock("../connection/catalog", () => ({
37 environmentCatalog: { stateAtom: mocks.connectionStateAtom },
38}));
39
40vi.mock("../state/assets", () => ({
41 assetEnvironment: { createUrl: mocks.createAssetUrl },
42}));
43
44vi.mock("../state/attachments", () => ({
45 attachmentEnvironment: {
46 createUploadUrl: mocks.createUploadUrl,
47 remove: mocks.removeUpload,
48 },
49}));
50
51vi.mock("../state/session", () => ({
52 readEnvironmentScope: mocks.readEnvironmentScope,
53 readPreparedConnection: mocks.readPreparedConnection,
54}));
55
56import {
57 awaitAttachmentUploads,
58 getUploadedAttachments,
59 readAttachmentUpload,
60 releaseAttachmentUpload,
61 releaseDraftAttachment,
62 releaseDraftAttachments,
63 releasePersistedAttachmentUpload,
64 retryAttachmentUpload,
65 startAttachmentUpload,
66 useAttachmentUploadStore,
67} from "./attachmentUploadQueue";
68
69type ProgressListener = (event: {
70 readonly lengthComputable: boolean;
71 readonly loaded: number;
72 readonly total: number;
73}) => void;
74
75class TestXmlHttpRequest {
76 static requests: TestXmlHttpRequest[] = [];
77
78 status = 0;
79 timeout = 0;
80 method: string | null = null;
81 url: string | null = null;
82 readonly headers = new Map<string, string>();
83 readonly listeners = new Map<string, () => void>();
84 progressListener: ProgressListener | null = null;
85
86 readonly upload = {
87 addEventListener: (_event: string, listener: ProgressListener) => {
88 this.progressListener = listener;
89 },
90 };
91
92 constructor() {
93 TestXmlHttpRequest.requests.push(this);
94 }
95
96 open(method: string, url: string): void {
97 this.method = method;
98 this.url = url;
99 }
100
101 setRequestHeader(name: string, value: string): void {
102 this.headers.set(name, value);
103 }
104
105 addEventListener(event: string, listener: () => void): void {
106 this.listeners.set(event, listener);
107 }
108
109 send(): void {}
110
111 abort(): void {
112 this.listeners.get("abort")?.();
113 }
114
115 progress(loaded: number, total: number): void {
116 this.progressListener?.({ lengthComputable: true, loaded, total });
117 }
118
119 complete(status = 204): void {
120 this.status = status;
121 this.listeners.get("load")?.();
122 }
123}
124
125const firstEnvironment = EnvironmentId.make("environment-1");
126const secondEnvironment = EnvironmentId.make("environment-2");
127
128function makeImage(id: string): ComposerImageAttachment {
129 const file = new File([new Uint8Array([1, 2, 3])], `${id}.png`, { type: "image/png" });
130 return {
131 type: "image",
132 id,
133 name: file.name,
134 mimeType: file.type,
135 sizeBytes: file.size,
136 previewUrl: `blob:${id}`,
137 file,
138 };
139}
140
141function makeFile(id: string): ComposerFileAttachment {
142 const file = new File([new Uint8Array([1, 2, 3])], `${id}.pdf`, {
143 type: "application/pdf",
144 });
145 return {
146 type: "file",
147 id,
148 name: file.name,
149 mimeType: file.type,
150 sizeBytes: file.size,
151 file,
152 };
153}
154
155const connectionStates = Atom.family((_environmentId: EnvironmentId) =>
156 Atom.make(AsyncResult.success({ phase: "connected" })),
157);
158
159function setConnected(environmentId: EnvironmentId, connected: boolean) {
160 appAtomRegistry.set(
161 connectionStates(environmentId),
162 AsyncResult.success({ phase: connected ? "connected" : "backoff" }),
163 );
164}
165
166describe("attachmentUploadQueue", () => {
167 beforeEach(() => {
168 mocks.connectionStateAtom.mockImplementation(connectionStates);
169 setConnected(firstEnvironment, true);
170 setConnected(secondEnvironment, true);
171 TestXmlHttpRequest.requests = [];
172 mocks.createAssetUrl.mockReset();
173 mocks.createAssetUrl.mockImplementation((target: unknown) => target);
174 mocks.executeAtomQuery.mockReset();
175 mocks.executeAtomQuery.mockResolvedValue({ _tag: "Success", value: {} });
176 mocks.runAtomCommand.mockReset();
177 mocks.readEnvironmentScope.mockReset().mockReturnValue(true);
178 mocks.readPreparedConnection.mockReset();
179 mocks.readPreparedConnection.mockReturnValue({ httpBaseUrl: "https://environment.test/" });
180 mocks.runAtomCommand.mockImplementation(
181 async (
182 _registry: unknown,
183 command: unknown,
184 target: {
185 readonly environmentId: EnvironmentId;
186 readonly input: { readonly name?: string };
187 },
188 ) => {
189 if (command === mocks.createUploadUrl) {
190 const attachmentId = `pending-${target.environmentId}-${target.input.name}`;
191 return {
192 _tag: "Success",
193 value: {
194 attachmentId,
195 relativeUrl: `/api/attachments/upload/${attachmentId}`,
196 expiresAt: 1,
197 },
198 };
199 }
200 return { _tag: "Success", value: undefined };
201 },
202 );
203 vi.stubGlobal("XMLHttpRequest", TestXmlHttpRequest);
204 });
205
206 afterEach(() => {
207 for (const imageId of Object.keys(useAttachmentUploadStore.getState().uploadsByImageId)) {
208 releaseAttachmentUpload(imageId);
209 }
210 vi.unstubAllGlobals();
211 });
212
213 it.each([false, true])(
214 "retries a failed file once after reconnect, including a late HTTP failure: %s",
215 async (lateFailure) => {
216 const image = makeFile("reconnect");
217 startAttachmentUpload({ environmentId: firstEnvironment, image });
218 await Promise.resolve();
219 const firstSettled = awaitAttachmentUploads([image.id]);
220 setConnected(firstEnvironment, false);
221 if (lateFailure) setConnected(firstEnvironment, true);
222 TestXmlHttpRequest.requests[0]!.complete(503);
223 await firstSettled;
224 if (!lateFailure) setConnected(firstEnvironment, true);
225 await Promise.resolve();
226 await Promise.resolve();
227 expect(TestXmlHttpRequest.requests).toHaveLength(2);
228 const retrySettled = awaitAttachmentUploads([image.id]);
229 TestXmlHttpRequest.requests[1]!.complete(503);
230 await retrySettled;
231 setConnected(firstEnvironment, true);
232 await Promise.resolve();
233 expect(TestXmlHttpRequest.requests).toHaveLength(2);
234 expect(readAttachmentUpload(image.id)?.status).toBe("failed");
235
236 setConnected(firstEnvironment, false);
237 setConnected(firstEnvironment, true);
238 await Promise.resolve();
239 await Promise.resolve();
240 const finalSettled = awaitAttachmentUploads([image.id]);
241 TestXmlHttpRequest.requests[2]!.complete();
242 await finalSettled;
243 expect(
244 getUploadedAttachments({ environmentId: firstEnvironment, images: [image] }),
245 ).not.toBeNull();
246 setConnected(firstEnvironment, false);
247 setConnected(firstEnvironment, true);
248 await Promise.resolve();
249 expect(TestXmlHttpRequest.requests).toHaveLength(3);
250 },
251 );
252
253 it("does not retry for another environment or after the attachment is removed", async () => {
254 const image = makeFile("removed");
255 startAttachmentUpload({ environmentId: firstEnvironment, image });
256 await Promise.resolve();
257 const settled = awaitAttachmentUploads([image.id]);
258 TestXmlHttpRequest.requests[0]!.complete(503);
259 await settled;
260 setConnected(secondEnvironment, false);
261 setConnected(secondEnvironment, true);
262 await Promise.resolve();
263 expect(TestXmlHttpRequest.requests).toHaveLength(1);
264 setConnected(firstEnvironment, false);
265 setConnected(firstEnvironment, true);
266 releaseAttachmentUpload(image.id);
267 await Promise.resolve();
268 expect(TestXmlHttpRequest.requests).toHaveLength(1);
269 expect(readAttachmentUpload(image.id)).toBeUndefined();
270 });
271
272 it("keeps an attachment local until its own environment grants write access", async () => {
273 const image = makeImage("permission-gain");
274 mocks.readEnvironmentScope.mockImplementation(
275 (environmentId) => environmentId === firstEnvironment,
276 );
277
278 startAttachmentUpload({ environmentId: secondEnvironment, image });
279 expect(mocks.runAtomCommand).not.toHaveBeenCalled();
280 expect(TestXmlHttpRequest.requests).toHaveLength(0);
281 expect(readAttachmentUpload(image.id)).toBeUndefined();
282 expect(image.file).not.toBeNull();
283
284 mocks.readEnvironmentScope.mockReturnValue(true);
285 startAttachmentUpload({ environmentId: secondEnvironment, image });
286 await Promise.resolve();
287 const settled = awaitAttachmentUploads([image.id]);
288 TestXmlHttpRequest.requests[0]!.complete();
289 await settled;
290
291 expect(readAttachmentUpload(image.id)).toMatchObject({
292 status: "ready",
293 environmentId: secondEnvironment,
294 });
295 });
296
297 it("does not transfer bytes or retry when access is revoked while minting an upload", async () => {
298 const image = makeImage("revoke-before-bytes");
299 let finishMint: (() => void) | undefined;
300 mocks.runAtomCommand.mockImplementationOnce(
301 () =>
302 new Promise((resolve) => {
303 finishMint = () =>
304 resolve({
305 _tag: "Success",
306 value: {
307 attachmentId: "pending-revoked",
308 relativeUrl: "/api/attachments/upload/pending-revoked",
309 expiresAt: 1,
310 },
311 });
312 }),
313 );
314 startAttachmentUpload({ environmentId: firstEnvironment, image });
315 const settled = awaitAttachmentUploads([image.id]);
316 mocks.readEnvironmentScope.mockReturnValue(false);
317 finishMint!();
318 await settled;
319
320 expect(TestXmlHttpRequest.requests).toHaveLength(0);
321 expect(readAttachmentUpload(image.id)).toMatchObject({
322 status: "failed",
323 reason: "This connection cannot upload attachments.",
324 attachmentId: "pending-revoked",
325 });
326 retryAttachmentUpload({ environmentId: firstEnvironment, image });
327 expect(readAttachmentUpload(image.id)?.status).toBe("failed");
328 releaseAttachmentUpload(image.id);
329 expect(readAttachmentUpload(image.id)).toBeUndefined();
330 expect(mocks.runAtomCommand).toHaveBeenCalledTimes(1);
331 });
332
333 it("does not mint queued uploads after access is revoked", async () => {
334 const images = Array.from({ length: 4 }, (_, index) => makeImage(`queued-${index}`));
335 for (const image of images) {
336 startAttachmentUpload({ environmentId: firstEnvironment, image });
337 }
338 await Promise.resolve();
339 expect(TestXmlHttpRequest.requests).toHaveLength(3);
340 const settled = awaitAttachmentUploads(images.map((image) => image.id));
341 mocks.readEnvironmentScope.mockReturnValue(false);
342 for (const request of TestXmlHttpRequest.requests) {
343 request.complete();
344 }
345 await settled;
346
347 expect(mocks.runAtomCommand).toHaveBeenCalledTimes(3);
348 expect(TestXmlHttpRequest.requests).toHaveLength(3);
349 expect(readAttachmentUpload(images[3]!.id)).toMatchObject({
350 status: "failed",
351 reason: "This connection cannot upload attachments.",
352 });
353 });
354
355 it("does not delete a persisted upload using another environment's grant", () => {
356 mocks.readEnvironmentScope.mockImplementation(
357 (environmentId) => environmentId === firstEnvironment,
358 );
359 releasePersistedAttachmentUpload({
360 id: "local-file",
361 environmentId: secondEnvironment,
362 attachmentId: "persisted-file",
363 });
364 expect(mocks.runAtomCommand).not.toHaveBeenCalled();
365
366 mocks.readEnvironmentScope.mockReturnValue(true);
367 releasePersistedAttachmentUpload({
368 id: "local-file",
369 environmentId: secondEnvironment,
370 attachmentId: "persisted-file",
371 });
372 expect(mocks.runAtomCommand).toHaveBeenCalledWith(
373 expect.anything(),
374 mocks.removeUpload,
375 { environmentId: secondEnvironment, input: { attachmentId: "persisted-file" } },
376 expect.anything(),
377 );
378 });
379
380 it("uploads images immediately and sends attachment references", async () => {
381 const image = {
382 ...makeImage("image-1"),
383 source: {
384 kind: "snap-shot" as const,
385 capturedAt: "2026-08-24T11:00:00.000Z",
386 appName: "Terminal",
387 windowTitle: "Tests",
388 },
389 };
390 startAttachmentUpload({ environmentId: firstEnvironment, image });
391 await Promise.resolve();
392
393 const request = TestXmlHttpRequest.requests[0]!;
394 expect(request.method).toBe("POST");
395 expect(request.url).toBe(
396 "https://environment.test/api/attachments/upload/pending-environment-1-image-1.png",
397 );
398 request.progress(1, 3);
399 expect(readAttachmentUpload(image.id)).toMatchObject({ status: "uploading", progress: 1 / 3 });
400
401 const settled = awaitAttachmentUploads([image.id]);
402 request.complete();
403 await settled;
404
405 expect(getUploadedAttachments({ environmentId: firstEnvironment, images: [image] })).toEqual([
406 {
407 type: "image",
408 id: "pending-environment-1-image-1.png",
409 name: "image-1.png",
410 mimeType: "image/png",
411 sizeBytes: 3,
412 source: {
413 kind: "snap-shot",
414 capturedAt: "2026-08-24T11:00:00.000Z",
415 appName: "Terminal",
416 windowTitle: "Tests",
417 },
418 },
419 ]);
420
421 releaseDraftAttachments([image]);
422 expect(readAttachmentUpload(image.id)).toBeUndefined();
423 expect(mocks.runAtomCommand).toHaveBeenCalledWith(
424 expect.anything(),
425 mocks.removeUpload,
426 {
427 environmentId: firstEnvironment,
428 input: { attachmentId: "pending-environment-1-image-1.png" },
429 },
430 expect.anything(),
431 );
432 });
433
434 it("uploads generic files and sends file attachment references", async () => {
435 const file = {
436 ...makeFile("report"),
437 source: { _tag: "pasted-text" as const },
438 };
439 startAttachmentUpload({ environmentId: firstEnvironment, image: file });
440 await Promise.resolve();
441
442 expect(mocks.runAtomCommand).toHaveBeenCalledWith(
443 expect.anything(),
444 mocks.createUploadUrl,
445 {
446 environmentId: firstEnvironment,
447 input: {
448 type: "file",
449 name: "report.pdf",
450 mimeType: "application/pdf",
451 sizeBytes: 3,
452 },
453 },
454 expect.anything(),
455 );
456
457 const settled = awaitAttachmentUploads([file.id]);
458 TestXmlHttpRequest.requests[0]!.complete();
459 await settled;
460
461 expect(getUploadedAttachments({ environmentId: firstEnvironment, images: [file] })).toEqual([
462 {
463 type: "file",
464 id: "pending-environment-1-report.pdf",
465 name: "report.pdf",
466 mimeType: "application/pdf",
467 sizeBytes: 3,
468 source: { _tag: "pasted-text" },
469 },
470 ]);
471 });
472
473 it("uses the fallback MIME type for both the upload claim and request header", async () => {
474 const bytes = new File([new Uint8Array([1, 2, 3])], "unknown.bin");
475 const file: ComposerFileAttachment = {
476 type: "file",
477 id: "file-without-browser-mime",
478 name: bytes.name,
479 mimeType: "application/octet-stream",
480 sizeBytes: bytes.size,
481 file: bytes,
482 };
483
484 startAttachmentUpload({ environmentId: firstEnvironment, image: file });
485 await Promise.resolve();
486
487 expect(mocks.runAtomCommand).toHaveBeenCalledWith(
488 expect.anything(),
489 mocks.createUploadUrl,
490 {
491 environmentId: firstEnvironment,
492 input: {
493 type: "file",
494 name: "unknown.bin",
495 mimeType: "application/octet-stream",
496 sizeBytes: 3,
497 },
498 },
499 expect.anything(),
500 );
501 expect(TestXmlHttpRequest.requests[0]?.headers.get("Content-Type")).toBe(
502 "application/octet-stream",
503 );
504
505 const settled = awaitAttachmentUploads([file.id]);
506 TestXmlHttpRequest.requests[0]!.complete();
507 await settled;
508 });
509
510 it("persists a background completion's ids to the draft with no composer mounted", async () => {
511 const draftId = DraftId.make("draft-background-upload");
512 const file = makeFile("background");
513 const draftStore = useComposerDraftStore.getState();
514 draftStore.addFiles(draftId, [file]);
515 try {
516 startAttachmentUpload({
517 environmentId: firstEnvironment,
518 image: file,
519 draftTarget: draftId,
520 });
521 await Promise.resolve();
522
523 // No composer effect is subscribed; only the queue can stamp the draft.
524 const settled = awaitAttachmentUploads([file.id]);
525 TestXmlHttpRequest.requests[0]!.complete();
526 await settled;
527
528 expect(useComposerDraftStore.getState().getComposerDraft(draftId)?.files).toMatchObject([
529 {
530 id: file.id,
531 uploadedAttachmentId: "pending-environment-1-background.pdf",
532 uploadEnvironmentId: firstEnvironment,
533 },
534 ]);
535 } finally {
536 useComposerDraftStore.getState().clearComposerContent(draftId);
537 }
538 });
539
540 it("persists a retried question upload without a mounted composer", async () => {
541 const draftId = DraftId.make("question-retry-upload");
542 const file = makeFile("question-retry");
543 const store = useComposerDraftStore.getState();
544 store.addFiles(draftId, [file]);
545 try {
546 startAttachmentUpload({ environmentId: firstEnvironment, image: file, draftTarget: draftId });
547 await Promise.resolve();
548 let settled = awaitAttachmentUploads([file.id]);
549 TestXmlHttpRequest.requests[0]!.complete(500);
550 await settled;
551 expect(store.getComposerDraft(draftId)?.files[0]?.uploadedAttachmentId).toBeUndefined();
552 retryAttachmentUpload({ environmentId: firstEnvironment, image: file, draftTarget: draftId });
553 await Promise.resolve();
554 settled = awaitAttachmentUploads([file.id]);
555 TestXmlHttpRequest.requests[1]!.complete();
556 await settled;
557 expect(store.getComposerDraft(draftId)?.files[0]).toMatchObject({
558 uploadedAttachmentId: "pending-environment-1-question-retry.pdf",
559 uploadEnvironmentId: firstEnvironment,
560 });
561 } finally {
562 store.clearComposerContent(draftId);
563 }
564 });
565
566 it("verifies an uploaded file reference before restoring it", async () => {
567 const file: ComposerFileAttachment = {
568 ...makeFile("restored"),
569 file: null,
570 uploadedAttachmentId: "pending-restored-pdf",
571 uploadEnvironmentId: firstEnvironment,
572 };
573
574 startAttachmentUpload({ environmentId: firstEnvironment, image: file });
575 await awaitAttachmentUploads([file.id]);
576
577 expect(readAttachmentUpload(file.id)).toEqual({
578 status: "ready",
579 environmentId: firstEnvironment,
580 attachmentId: "pending-restored-pdf",
581 });
582 expect(mocks.createAssetUrl).toHaveBeenCalledWith({
583 environmentId: firstEnvironment,
584 input: { resource: { _tag: "attachment", attachmentId: "pending-restored-pdf" } },
585 });
586 expect(TestXmlHttpRequest.requests).toHaveLength(0);
587 });
588
589 it("turns an expired persisted file into a marker that a re-pick can replace", async () => {
590 const draftId = DraftId.make("draft-expired-upload");
591 const file: ComposerFileAttachment = {
592 ...makeFile("expired"),
593 file: null,
594 uploadedAttachmentId: "pending-expired-pdf",
595 uploadEnvironmentId: firstEnvironment,
596 };
597 mocks.executeAtomQuery.mockResolvedValueOnce({
598 _tag: "Failure",
599 error: { _tag: "AssetAttachmentNotFoundError" },
600 });
601 useComposerDraftStore.getState().addFiles(draftId, [file]);
602
603 try {
604 startAttachmentUpload({
605 environmentId: firstEnvironment,
606 image: file,
607 draftTarget: draftId,
608 });
609 await awaitAttachmentUploads([file.id]);
610
611 const marker = useComposerDraftStore.getState().getComposerDraft(draftId)?.files[0];
612 expect(readAttachmentUpload(file.id)).toBeUndefined();
613 expect(marker?.uploadedAttachmentId).toBeUndefined();
614 expect(marker?.uploadEnvironmentId).toBeUndefined();
615 expect(marker && composerFileNeedsReattach(marker)).toBe(true);
616
617 const replacementBytes = new File([new Uint8Array([1, 2, 3])], file.name, {
618 type: file.mimeType,
619 });
620 const replacement: ComposerFileAttachment = {
621 type: "file",
622 id: "expired-repicked",
623 name: file.name,
624 mimeType: file.mimeType,
625 sizeBytes: file.sizeBytes,
626 file: replacementBytes,
627 };
628 useComposerDraftStore.getState().addFiles(draftId, [replacement]);
629 expect(useComposerDraftStore.getState().getComposerDraft(draftId)?.files).toMatchObject([
630 { id: replacement.id, file: replacementBytes },
631 ]);
632
633 startAttachmentUpload({
634 environmentId: firstEnvironment,
635 image: replacement,
636 draftTarget: draftId,
637 });
638 await Promise.resolve();
639 const settled = awaitAttachmentUploads([replacement.id]);
640 TestXmlHttpRequest.requests[0]!.complete();
641 await settled;
642
643 expect(useComposerDraftStore.getState().getComposerDraft(draftId)?.files).toMatchObject([
644 {
645 id: replacement.id,
646 uploadedAttachmentId: "pending-environment-1-expired.pdf",
647 uploadEnvironmentId: firstEnvironment,
648 },
649 ]);
650 const removeCalls = mocks.runAtomCommand.mock.calls.filter(
651 ([, command]) => command === mocks.removeUpload,
652 );
653 expect(removeCalls).toEqual([]);
654 } finally {
655 useComposerDraftStore.getState().clearComposerContent(draftId);
656 }
657 });
658
659 it("uploads the original file again when its persisted server upload expired", async () => {
660 const file: ComposerFileAttachment = {
661 ...makeFile("recoverable"),
662 uploadedAttachmentId: "pending-expired-pdf",
663 uploadEnvironmentId: firstEnvironment,
664 };
665 mocks.executeAtomQuery.mockResolvedValueOnce({
666 _tag: "Failure",
667 error: { _tag: "AssetAttachmentNotFoundError" },
668 });
669
670 startAttachmentUpload({ environmentId: firstEnvironment, image: file });
671 // The verify-then-reupload path crosses several awaits before the
672 // transfer starts; drain microtasks until the XHR exists.
673 for (let hop = 0; hop < 20 && TestXmlHttpRequest.requests.length === 0; hop += 1) {
674 await Promise.resolve();
675 }
676
677 const settled = awaitAttachmentUploads([file.id]);
678 TestXmlHttpRequest.requests[0]!.complete();
679 await settled;
680
681 expect(readAttachmentUpload(file.id)).toMatchObject({
682 status: "ready",
683 attachmentId: "pending-environment-1-recoverable.pdf",
684 });
685 });
686
687 it("removes a persisted upload when its draft is discarded during verification", async () => {
688 const file: ComposerFileAttachment = {
689 ...makeFile("checking"),
690 file: null,
691 uploadedAttachmentId: "pending-checking-pdf",
692 uploadEnvironmentId: firstEnvironment,
693 };
694 let resolveVerification: (result: {
695 readonly _tag: "Success";
696 readonly value: object;
697 }) => void = () => {};
698 mocks.executeAtomQuery.mockReturnValueOnce(
699 new Promise((resolve) => {
700 resolveVerification = resolve;
701 }),
702 );
703
704 startAttachmentUpload({ environmentId: firstEnvironment, image: file });
705 releaseDraftAttachment(file);
706 resolveVerification({ _tag: "Success", value: {} });
707
708 expect(mocks.runAtomCommand).toHaveBeenCalledWith(
709 expect.anything(),
710 mocks.removeUpload,
711 {
712 environmentId: firstEnvironment,
713 input: { attachmentId: "pending-checking-pdf" },
714 },
715 expect.anything(),
716 );
717 });
718
719 it("keeps the persisted upload when retrying after a transient verification failure", async () => {
720 const draftId = DraftId.make("draft-transient-verification");
721 const file: ComposerFileAttachment = {
722 ...makeFile("flaky"),
723 file: null,
724 uploadedAttachmentId: "pending-flaky-pdf",
725 uploadEnvironmentId: firstEnvironment,
726 };
727 mocks.executeAtomQuery.mockResolvedValueOnce({
728 _tag: "Failure",
729 error: new Error("socket closed"),
730 });
731 useComposerDraftStore.getState().addFiles(draftId, [file]);
732
733 try {
734 startAttachmentUpload({
735 environmentId: firstEnvironment,
736 image: file,
737 draftTarget: draftId,
738 });
739 await awaitAttachmentUploads([file.id]);
740 expect(readAttachmentUpload(file.id)).toMatchObject({
741 status: "failed",
742 reason: "Uploaded file could not be verified. Retry when the server reconnects.",
743 });
744 expect(useComposerDraftStore.getState().getComposerDraft(draftId)?.files).toMatchObject([
745 {
746 uploadedAttachmentId: "pending-flaky-pdf",
747 uploadEnvironmentId: firstEnvironment,
748 },
749 ]);
750
751 // The persisted id is the only server copy of the bytes (`file` is null
752 // after a reload), so the retry must verify it again, not delete it.
753 retryAttachmentUpload({
754 environmentId: firstEnvironment,
755 image: file,
756 draftTarget: draftId,
757 });
758 await awaitAttachmentUploads([file.id]);
759
760 expect(readAttachmentUpload(file.id)).toEqual({
761 status: "ready",
762 environmentId: firstEnvironment,
763 attachmentId: "pending-flaky-pdf",
764 });
765 expect(useComposerDraftStore.getState().getComposerDraft(draftId)?.files).toMatchObject([
766 {
767 uploadedAttachmentId: "pending-flaky-pdf",
768 uploadEnvironmentId: firstEnvironment,
769 },
770 ]);
771 const removeCalls = mocks.runAtomCommand.mock.calls.filter(
772 ([, command]) => command === mocks.removeUpload,
773 );
774 expect(removeCalls).toEqual([]);
775 } finally {
776 useComposerDraftStore.getState().clearComposerContent(draftId);
777 }
778 });
779
780 it("keeps the persisted upload when an environment switch cancels its verification", async () => {
781 const file: ComposerFileAttachment = {
782 ...makeFile("moving"),
783 file: null,
784 uploadedAttachmentId: "pending-moving-pdf",
785 uploadEnvironmentId: firstEnvironment,
786 };
787 let resolveVerification: (result: {
788 readonly _tag: "Success";
789 readonly value: object;
790 }) => void = () => {};
791 mocks.executeAtomQuery.mockReturnValueOnce(
792 new Promise((resolve) => {
793 resolveVerification = resolve;
794 }),
795 );
796
797 startAttachmentUpload({ environmentId: firstEnvironment, image: file });
798 // Switching environments cancels the in-flight verification. The draft
799 // still references the upload in the first environment, so the cancel
800 // must not delete it.
801 startAttachmentUpload({ environmentId: secondEnvironment, image: file });
802 resolveVerification({ _tag: "Success", value: {} });
803 await awaitAttachmentUploads([file.id]);
804
805 const persistedRemoveCalls = mocks.runAtomCommand.mock.calls.filter(
806 ([, command, target]) =>
807 command === mocks.removeUpload &&
808 (target as { readonly input: { readonly attachmentId: string } }).input.attachmentId ===
809 "pending-moving-pdf",
810 );
811 expect(persistedRemoveCalls).toEqual([]);
812 });
813
814 it("cancels persisted-upload verification when a stash discards its file", async () => {
815 const file: ComposerFileAttachment = {
816 ...makeFile("stashed-checking"),
817 file: null,
818 uploadedAttachmentId: "pending-stashed-checking-pdf",
819 uploadEnvironmentId: firstEnvironment,
820 };
821 let resolveVerification: (result: {
822 readonly _tag: "Success";
823 readonly value: object;
824 }) => void = () => {};
825 mocks.executeAtomQuery.mockReturnValueOnce(
826 new Promise((resolve) => {
827 resolveVerification = resolve;
828 }),
829 );
830
831 startAttachmentUpload({ environmentId: firstEnvironment, image: file });
832 releasePersistedAttachmentUpload({
833 id: file.id,
834 environmentId: firstEnvironment,
835 attachmentId: "pending-stashed-checking-pdf",
836 });
837 resolveVerification({ _tag: "Success", value: {} });
838 await Promise.resolve();
839
840 expect(readAttachmentUpload(file.id)).toBeUndefined();
841 expect(mocks.runAtomCommand).toHaveBeenCalledWith(
842 expect.anything(),
843 mocks.removeUpload,
844 {
845 environmentId: firstEnvironment,
846 input: { attachmentId: "pending-stashed-checking-pdf" },
847 },
848 expect.anything(),
849 );
850 });
851
852 it("releases the persisted server upload when a hydrated draft is discarded after a reload", () => {
853 // After a reload the in-memory queue is empty; the draft file only carries
854 // its persisted attachment id. Discarding it must still delete the
855 // server-side pending upload.
856 const file: ComposerFileAttachment = {
857 ...makeFile("hydrated"),
858 file: null,
859 uploadedAttachmentId: "pending-hydrated-pdf",
860 uploadEnvironmentId: firstEnvironment,
861 };
862
863 releaseDraftAttachment(file);
864
865 expect(mocks.runAtomCommand).toHaveBeenCalledWith(
866 expect.anything(),
867 mocks.removeUpload,
868 {
869 environmentId: firstEnvironment,
870 input: { attachmentId: "pending-hydrated-pdf" },
871 },
872 expect.anything(),
873 );
874 });
875
876 it("routes a live persisted upload through the queue release exactly once", async () => {
877 const file: ComposerFileAttachment = {
878 ...makeFile("live"),
879 file: null,
880 uploadedAttachmentId: "pending-live-pdf",
881 uploadEnvironmentId: firstEnvironment,
882 };
883 startAttachmentUpload({ environmentId: firstEnvironment, image: file });
884 await awaitAttachmentUploads([file.id]);
885 expect(readAttachmentUpload(file.id)).toMatchObject({ status: "ready" });
886
887 releaseDraftAttachment(file);
888
889 expect(readAttachmentUpload(file.id)).toBeUndefined();
890 const removeCalls = mocks.runAtomCommand.mock.calls.filter(
891 ([, command]) => command === mocks.removeUpload,
892 );
893 expect(removeCalls).toEqual([
894 [
895 expect.anything(),
896 mocks.removeUpload,
897 {
898 environmentId: firstEnvironment,
899 input: { attachmentId: "pending-live-pdf" },
900 },
901 expect.anything(),
902 ],
903 ]);
904 });
905
906 it("deletes a persisted server upload even when browser upload state is gone", () => {
907 releasePersistedAttachmentUpload({
908 id: "stashed-report",
909 environmentId: firstEnvironment,
910 attachmentId: "pending-00000000-0000-4000-8000-000000000001-pdf",
911 });
912
913 expect(mocks.runAtomCommand).toHaveBeenCalledWith(
914 expect.anything(),
915 mocks.removeUpload,
916 {
917 environmentId: firstEnvironment,
918 input: { attachmentId: "pending-00000000-0000-4000-8000-000000000001-pdf" },
919 },
920 expect.anything(),
921 );
922 });
923
924 it("retries rejected uploads", async () => {
925 const image = makeImage("image-retry");
926 startAttachmentUpload({ environmentId: firstEnvironment, image });
927 await Promise.resolve();
928
929 let settled = awaitAttachmentUploads([image.id]);
930 TestXmlHttpRequest.requests[0]!.complete(500);
931 await settled;
932 expect(readAttachmentUpload(image.id)).toMatchObject({
933 status: "failed",
934 reason: "Upload rejected (500)",
935 });
936
937 retryAttachmentUpload({ environmentId: firstEnvironment, image });
938 await Promise.resolve();
939 settled = awaitAttachmentUploads([image.id]);
940 TestXmlHttpRequest.requests[1]!.complete();
941 await settled;
942
943 expect(readAttachmentUpload(image.id)).toMatchObject({ status: "ready" });
944 });
945
946 it("releases an upload URL that resolves after its image was removed", async () => {
947 const image = makeImage("image-cancelled");
948 const minted = {
949 _tag: "Success" as const,
950 value: {
951 attachmentId: "pending-environment-1-image-cancelled.png",
952 relativeUrl: "/api/attachments/upload/cancelled",
953 expiresAt: 1,
954 },
955 };
956 let resolveMint: (result: typeof minted) => void = () => {};
957 const pendingMint = new Promise<typeof minted>((resolve) => {
958 resolveMint = resolve;
959 });
960 let resolveDelete: () => void = () => {};
961 const deleted = new Promise<void>((resolve) => {
962 resolveDelete = resolve;
963 });
964 mocks.runAtomCommand.mockImplementation((_registry: unknown, command: unknown) => {
965 if (command === mocks.createUploadUrl) {
966 return pendingMint;
967 }
968 resolveDelete();
969 return Promise.resolve({ _tag: "Success", value: undefined });
970 });
971
972 startAttachmentUpload({ environmentId: firstEnvironment, image });
973 releaseAttachmentUpload(image.id);
974 resolveMint(minted);
975 await deleted;
976
977 expect(TestXmlHttpRequest.requests).toEqual([]);
978 expect(readAttachmentUpload(image.id)).toBeUndefined();
979 expect(mocks.runAtomCommand).toHaveBeenCalledWith(
980 expect.anything(),
981 mocks.removeUpload,
982 {
983 environmentId: firstEnvironment,
984 input: { attachmentId: minted.value.attachmentId },
985 },
986 expect.anything(),
987 );
988 });
989
990 it("restores the previous environment after a replacement upload fails", async () => {
991 const image = makeImage("image-move");
992 startAttachmentUpload({ environmentId: firstEnvironment, image });
993 await Promise.resolve();
994 let settled = awaitAttachmentUploads([image.id]);
995 TestXmlHttpRequest.requests[0]!.complete();
996 await settled;
997
998 startAttachmentUpload({ environmentId: secondEnvironment, image });
999 await Promise.resolve();
1000 settled = awaitAttachmentUploads([image.id]);
1001 TestXmlHttpRequest.requests[1]!.complete(500);
1002 await settled;
1003
1004 startAttachmentUpload({ environmentId: firstEnvironment, image });
1005 expect(readAttachmentUpload(image.id)).toMatchObject({
1006 status: "ready",
1007 environmentId: firstEnvironment,
1008 attachmentId: "pending-environment-1-image-move.png",
1009 });
1010 });
1011
1012 it("does not let stalled uploads block another environment", async () => {
1013 const images = ["image-a", "image-b", "image-c", "image-d"].map(makeImage);
1014 for (const image of images) {
1015 startAttachmentUpload({ environmentId: firstEnvironment, image });
1016 }
1017 const otherEnvironmentImage = makeImage("image-other");
1018 startAttachmentUpload({ environmentId: secondEnvironment, image: otherEnvironmentImage });
1019 await Promise.resolve();
1020
1021 expect(TestXmlHttpRequest.requests).toHaveLength(4);
1022 const otherRequest = TestXmlHttpRequest.requests.find((request) =>
1023 request.url?.includes("environment-2"),
1024 );
1025 expect(otherRequest).toBeDefined();
1026
1027 for (const request of TestXmlHttpRequest.requests) {
1028 request.complete();
1029 }
1030 await Promise.all([
1031 ...images.slice(0, 3).map((image) => awaitAttachmentUploads([image.id])),
1032 awaitAttachmentUploads([otherEnvironmentImage.id]),
1033 ]);
1034 await Promise.resolve();
1035 TestXmlHttpRequest.requests[4]!.complete();
1036 await awaitAttachmentUploads([images[3]!.id]);
1037 });
1038});