Skip to content
Source
Sign inSign up
Open History to see the last commit

attachmentUploadQueue.test.ts

691 lines22.2 KB

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 }
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 };
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 };
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 );
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("keeps an attachment local until its own environment grants write access", async () => {
214 const image = makeImage("permission-gain");
215 mocks.readEnvironmentScope.mockImplementation(
216 (environmentId) => environmentId === firstEnvironment,
217 );
218​
219 startAttachmentUpload({ environmentId: secondEnvironment, image });
220 expect(mocks.runAtomCommand).not.toHaveBeenCalled();
221 expect(TestXmlHttpRequest.requests).toHaveLength(0);
222 expect(readAttachmentUpload(image.id)).toBeUndefined();
223 expect(image.file).not.toBeNull();
224​
225 mocks.readEnvironmentScope.mockReturnValue(true);
226 startAttachmentUpload({ environmentId: secondEnvironment, image });
227 await Promise.resolve();
228 const settled = awaitAttachmentUploads([image.id]);
229 TestXmlHttpRequest.requests[0]!.complete();
230 await settled;
231​
232 expect(readAttachmentUpload(image.id)).toMatchObject({
233 status: "ready",
234 environmentId: secondEnvironment,
235 });
236 });
237​
238 it("does not transfer bytes or retry when access is revoked while minting an upload", async () => {
239 const image = makeImage("revoke-before-bytes");
240 let finishMint: (() => void) | undefined;
241 mocks.runAtomCommand.mockImplementationOnce(
242 () =>
243 new Promise((resolve) => {
244 finishMint = () =>
245 resolve({
246 _tag: "Success",
247 value: {
248 attachmentId: "pending-revoked",
249 relativeUrl: "/api/attachments/upload/pending-revoked",
250 expiresAt: 1,
251 },
252 });
253 }),
254 );
255 startAttachmentUpload({ environmentId: firstEnvironment, image });
256 const settled = awaitAttachmentUploads([image.id]);
257 mocks.readEnvironmentScope.mockReturnValue(false);
258 finishMint!();
259 await settled;
260​
261 expect(TestXmlHttpRequest.requests).toHaveLength(0);
262 expect(readAttachmentUpload(image.id)).toMatchObject({
263 status: "failed",
264 reason: "This connection cannot upload attachments.",
265 attachmentId: "pending-revoked",
266 });
267 retryAttachmentUpload({ environmentId: firstEnvironment, image });
268 expect(readAttachmentUpload(image.id)?.status).toBe("failed");
269 releaseAttachmentUpload(image.id);
270 expect(readAttachmentUpload(image.id)).toBeUndefined();
271 expect(mocks.runAtomCommand).toHaveBeenCalledTimes(1);
272 });
273​
274 it("does not delete a persisted upload using another environment's grant", () => {
275 mocks.readEnvironmentScope.mockImplementation(
276 (environmentId) => environmentId === firstEnvironment,
277 );
278 releasePersistedAttachmentUpload({
279 id: "local-file",
280 environmentId: secondEnvironment,
281 attachmentId: "persisted-file",
282 });
283 expect(mocks.runAtomCommand).not.toHaveBeenCalled();
284​
285 mocks.readEnvironmentScope.mockReturnValue(true);
286 releasePersistedAttachmentUpload({
287 id: "local-file",
288 environmentId: secondEnvironment,
289 attachmentId: "persisted-file",
290 });
291 expect(mocks.runAtomCommand).toHaveBeenCalledWith(
292 expect.anything(),
293 mocks.removeUpload,
294 { environmentId: secondEnvironment, input: { attachmentId: "persisted-file" } },
295 expect.anything(),
296 );
297 });
298​
299 it("uploads images immediately and sends attachment references", async () => {
300 const image = {
301 ...makeImage("image-1"),
302 source: {
303 kind: "snap-shot" as const,
304 capturedAt: "2026-08-24T11:00:00.000Z",
305 appName: "Terminal",
306 windowTitle: "Tests",
307 },
308 };
309 startAttachmentUpload({ environmentId: firstEnvironment, image });
310 await Promise.resolve();
311​
312 const request = TestXmlHttpRequest.requests[0]!;
313 expect(request.method).toBe("POST");
314 expect(request.url).toBe(
315 "https://environment.test/api/attachments/upload/pending-environment-1-image-1.png",
316 );
317 request.progress(1, 3);
318 expect(readAttachmentUpload(image.id)).toMatchObject({ status: "uploading", progress: 1 / 3 });
319​
320 const settled = awaitAttachmentUploads([image.id]);
321 request.complete();
322 await settled;
323​
324 expect(getUploadedAttachments({ environmentId: firstEnvironment, images: [image] })).toEqual([
325 {
326 type: "image",
327 id: "pending-environment-1-image-1.png",
328 name: "image-1.png",
329 mimeType: "image/png",
330 sizeBytes: 3,
331 source: {
332 kind: "snap-shot",
333 capturedAt: "2026-08-24T11:00:00.000Z",
334 appName: "Terminal",
335 windowTitle: "Tests",
336 },
337 },
338 ]);
339​
340 releaseDraftAttachments([image]);
341 expect(readAttachmentUpload(image.id)).toBeUndefined();
342 expect(mocks.runAtomCommand).toHaveBeenCalledWith(
343 expect.anything(),
344 mocks.removeUpload,
345 {
346 environmentId: firstEnvironment,
347 input: { attachmentId: "pending-environment-1-image-1.png" },
348 },
349 expect.anything(),
350 );
351 });
352​
353 it("persists a background completion's ids to the draft with no composer mounted", async () => {
354 const draftId = DraftId.make("draft-background-upload");
355 const file = makeFile("background");
356 const draftStore = useComposerDraftStore.getState();
357 draftStore.addFiles(draftId, [file]);
358 try {
359 startAttachmentUpload({
360 environmentId: firstEnvironment,
361 image: file,
362 draftTarget: draftId,
363 });
364 await Promise.resolve();
365​
366 const settled = awaitAttachmentUploads([file.id]);
367 TestXmlHttpRequest.requests[0]!.complete();
368 await settled;
369​
370 expect(useComposerDraftStore.getState().getComposerDraft(draftId)?.files).toMatchObject([
371 {
372 id: file.id,
373 uploadedAttachmentId: "pending-environment-1-background.pdf",
374 uploadEnvironmentId: firstEnvironment,
375 },
376 ]);
377 } finally {
378 useComposerDraftStore.getState().clearComposerContent(draftId);
379 }
380 });
381​
382 it("stamps a file that lands in a draft after its upload finished", async () => {
383 const draftId = DraftId.make("draft-restored-upload");
384 const file = makeFile("restored");
385 try {
386 startAttachmentUpload({ environmentId: firstEnvironment, image: file, draftTarget: draftId });
387 await Promise.resolve();
388 const settled = awaitAttachmentUploads([file.id]);
389 TestXmlHttpRequest.requests[0]!.complete();
390 await settled;
391​
392 useComposerDraftStore.getState().addFiles(draftId, [file]);
393 startAttachmentUpload({ environmentId: firstEnvironment, image: file, draftTarget: draftId });
394​
395 expect(TestXmlHttpRequest.requests).toHaveLength(1);
396 expect(useComposerDraftStore.getState().getComposerDraft(draftId)?.files).toMatchObject([
397 {
398 id: file.id,
399 uploadedAttachmentId: "pending-environment-1-restored.pdf",
400 uploadEnvironmentId: firstEnvironment,
401 },
402 ]);
403 } finally {
404 useComposerDraftStore.getState().clearComposerContent(draftId);
405 }
406 });
407​
408 it("turns an expired persisted file into a marker that a re-pick can replace", async () => {
409 const draftId = DraftId.make("draft-expired-upload");
410 const file: ComposerFileAttachment = {
411 ...makeFile("expired"),
412 file: null,
413 uploadedAttachmentId: "pending-expired-pdf",
414 uploadEnvironmentId: firstEnvironment,
415 };
416 mocks.executeAtomQuery.mockResolvedValueOnce({
417 _tag: "Failure",
418 error: { _tag: "AssetAttachmentNotFoundError" },
419 });
420 useComposerDraftStore.getState().addFiles(draftId, [file]);
421​
422 try {
423 startAttachmentUpload({
424 environmentId: firstEnvironment,
425 image: file,
426 draftTarget: draftId,
427 });
428 await awaitAttachmentUploads([file.id]);
429​
430 const marker = useComposerDraftStore.getState().getComposerDraft(draftId)?.files[0];
431 expect(readAttachmentUpload(file.id)).toBeUndefined();
432 expect(marker?.uploadedAttachmentId).toBeUndefined();
433 expect(marker?.uploadEnvironmentId).toBeUndefined();
434 expect(marker && composerFileNeedsReattach(marker)).toBe(true);
435​
436 const replacementBytes = new File([new Uint8Array([1, 2, 3])], file.name, {
437 type: file.mimeType,
438 });
439 const replacement: ComposerFileAttachment = {
440 type: "file",
441 id: "expired-repicked",
442 name: file.name,
443 mimeType: file.mimeType,
444 sizeBytes: file.sizeBytes,
445 file: replacementBytes,
446 };
447 useComposerDraftStore.getState().addFiles(draftId, [replacement]);
448 expect(useComposerDraftStore.getState().getComposerDraft(draftId)?.files).toMatchObject([
449 { id: replacement.id, file: replacementBytes },
450 ]);
451​
452 startAttachmentUpload({
453 environmentId: firstEnvironment,
454 image: replacement,
455 draftTarget: draftId,
456 });
457 await Promise.resolve();
458 const settled = awaitAttachmentUploads([replacement.id]);
459 TestXmlHttpRequest.requests[0]!.complete();
460 await settled;
461​
462 expect(useComposerDraftStore.getState().getComposerDraft(draftId)?.files).toMatchObject([
463 {
464 id: replacement.id,
465 uploadedAttachmentId: "pending-environment-1-expired.pdf",
466 uploadEnvironmentId: firstEnvironment,
467 },
468 ]);
469 const removeCalls = mocks.runAtomCommand.mock.calls.filter(
470 ([, command]) => command === mocks.removeUpload,
471 );
472 expect(removeCalls).toEqual([]);
473 } finally {
474 useComposerDraftStore.getState().clearComposerContent(draftId);
475 }
476 });
477​
478 it("removes a persisted upload when its draft is discarded during verification", async () => {
479 const file: ComposerFileAttachment = {
480 ...makeFile("checking"),
481 file: null,
482 uploadedAttachmentId: "pending-checking-pdf",
483 uploadEnvironmentId: firstEnvironment,
484 };
485 let resolveVerification: (result: {
486 readonly _tag: "Success";
487 readonly value: object;
488 }) => void = () => {};
489 mocks.executeAtomQuery.mockReturnValueOnce(
490 new Promise((resolve) => {
491 resolveVerification = resolve;
492 }),
493 );
494​
495 startAttachmentUpload({ environmentId: firstEnvironment, image: file });
496 releaseDraftAttachment(file);
497 resolveVerification({ _tag: "Success", value: {} });
498​
499 expect(mocks.runAtomCommand).toHaveBeenCalledWith(
500 expect.anything(),
501 mocks.removeUpload,
502 {
503 environmentId: firstEnvironment,
504 input: { attachmentId: "pending-checking-pdf" },
505 },
506 expect.anything(),
507 );
508 });
509​
510 it("keeps the persisted upload when retrying after a transient verification failure", async () => {
511 const draftId = DraftId.make("draft-transient-verification");
512 const file: ComposerFileAttachment = {
513 ...makeFile("flaky"),
514 file: null,
515 uploadedAttachmentId: "pending-flaky-pdf",
516 uploadEnvironmentId: firstEnvironment,
517 };
518 mocks.executeAtomQuery.mockResolvedValueOnce({
519 _tag: "Failure",
520 error: new Error("socket closed"),
521 });
522 useComposerDraftStore.getState().addFiles(draftId, [file]);
523​
524 try {
525 startAttachmentUpload({
526 environmentId: firstEnvironment,
527 image: file,
528 draftTarget: draftId,
529 });
530 await awaitAttachmentUploads([file.id]);
531 expect(readAttachmentUpload(file.id)).toMatchObject({
532 status: "failed",
533 reason: "Uploaded file could not be verified. Retry when the server reconnects.",
534 });
535 expect(useComposerDraftStore.getState().getComposerDraft(draftId)?.files).toMatchObject([
536 {
537 uploadedAttachmentId: "pending-flaky-pdf",
538 uploadEnvironmentId: firstEnvironment,
539 },
540 ]);
541​
542 retryAttachmentUpload({
543 environmentId: firstEnvironment,
544 image: file,
545 draftTarget: draftId,
546 });
547 await awaitAttachmentUploads([file.id]);
548​
549 expect(readAttachmentUpload(file.id)).toEqual({
550 status: "ready",
551 environmentId: firstEnvironment,
552 attachmentId: "pending-flaky-pdf",
553 });
554 expect(useComposerDraftStore.getState().getComposerDraft(draftId)?.files).toMatchObject([
555 {
556 uploadedAttachmentId: "pending-flaky-pdf",
557 uploadEnvironmentId: firstEnvironment,
558 },
559 ]);
560 const removeCalls = mocks.runAtomCommand.mock.calls.filter(
561 ([, command]) => command === mocks.removeUpload,
562 );
563 expect(removeCalls).toEqual([]);
564 } finally {
565 useComposerDraftStore.getState().clearComposerContent(draftId);
566 }
567 });
568​
569 it("routes a live persisted upload through the queue release exactly once", async () => {
570 const file: ComposerFileAttachment = {
571 ...makeFile("live"),
572 file: null,
573 uploadedAttachmentId: "pending-live-pdf",
574 uploadEnvironmentId: firstEnvironment,
575 };
576 startAttachmentUpload({ environmentId: firstEnvironment, image: file });
577 await awaitAttachmentUploads([file.id]);
578 expect(readAttachmentUpload(file.id)).toMatchObject({ status: "ready" });
579​
580 releaseDraftAttachment(file);
581​
582 expect(readAttachmentUpload(file.id)).toBeUndefined();
583 const removeCalls = mocks.runAtomCommand.mock.calls.filter(
584 ([, command]) => command === mocks.removeUpload,
585 );
586 expect(removeCalls).toEqual([
587 [
588 expect.anything(),
589 mocks.removeUpload,
590 {
591 environmentId: firstEnvironment,
592 input: { attachmentId: "pending-live-pdf" },
593 },
594 expect.anything(),
595 ],
596 ]);
597 });
598​
599 it("releases an upload URL that resolves after its image was removed", async () => {
600 const image = makeImage("image-cancelled");
601 const minted = {
602 _tag: "Success" as const,
603 value: {
604 attachmentId: "pending-environment-1-image-cancelled.png",
605 relativeUrl: "/api/attachments/upload/cancelled",
606 expiresAt: 1,
607 },
608 };
609 let resolveMint: (result: typeof minted) => void = () => {};
610 const pendingMint = new Promise<typeof minted>((resolve) => {
611 resolveMint = resolve;
612 });
613 let resolveDelete: () => void = () => {};
614 const deleted = new Promise<void>((resolve) => {
615 resolveDelete = resolve;
616 });
617 mocks.runAtomCommand.mockImplementation((_registry: unknown, command: unknown) => {
618 if (command === mocks.createUploadUrl) {
619 return pendingMint;
620 }
621 resolveDelete();
622 return Promise.resolve({ _tag: "Success", value: undefined });
623 });
624​
625 startAttachmentUpload({ environmentId: firstEnvironment, image });
626 releaseAttachmentUpload(image.id);
627 resolveMint(minted);
628 await deleted;
629​
630 expect(TestXmlHttpRequest.requests).toEqual([]);
631 expect(readAttachmentUpload(image.id)).toBeUndefined();
632 expect(mocks.runAtomCommand).toHaveBeenCalledWith(
633 expect.anything(),
634 mocks.removeUpload,
635 {
636 environmentId: firstEnvironment,
637 input: { attachmentId: minted.value.attachmentId },
638 },
639 expect.anything(),
640 );
641 });
642​
643 it("restores the previous environment after a replacement upload fails", async () => {
644 const image = makeImage("image-move");
645 startAttachmentUpload({ environmentId: firstEnvironment, image });
646 await Promise.resolve();
647 let settled = awaitAttachmentUploads([image.id]);
648 TestXmlHttpRequest.requests[0]!.complete();
649 await settled;
650​
651 startAttachmentUpload({ environmentId: secondEnvironment, image });
652 await Promise.resolve();
653 settled = awaitAttachmentUploads([image.id]);
654 TestXmlHttpRequest.requests[1]!.complete(500);
655 await settled;
656​
657 startAttachmentUpload({ environmentId: firstEnvironment, image });
658 expect(readAttachmentUpload(image.id)).toMatchObject({
659 status: "ready",
660 environmentId: firstEnvironment,
661 attachmentId: "pending-environment-1-image-move.png",
662 });
663 });
664​
665 it("does not let stalled uploads block another environment", async () => {
666 const images = ["image-a", "image-b", "image-c", "image-d"].map(makeImage);
667 for (const image of images) {
668 startAttachmentUpload({ environmentId: firstEnvironment, image });
669 }
670 const otherEnvironmentImage = makeImage("image-other");
671 startAttachmentUpload({ environmentId: secondEnvironment, image: otherEnvironmentImage });
672 await Promise.resolve();
673​
674 expect(TestXmlHttpRequest.requests).toHaveLength(4);
675 const otherRequest = TestXmlHttpRequest.requests.find((request) =>
676 request.url?.includes("environment-2"),
677 );
678 expect(otherRequest).toBeDefined();
679​
680 for (const request of TestXmlHttpRequest.requests) {
681 request.complete();
682 }
683 await Promise.all([
684 ...images.slice(0, 3).map((image) => awaitAttachmentUploads([image.id])),
685 awaitAttachmentUploads([otherEnvironmentImage.id]),
686 ]);
687 await Promise.resolve();
688 TestXmlHttpRequest.requests[4]!.complete();
689 await awaitAttachmentUploads([images[3]!.id]);
690 });
691});