Skip to content
Source
Sign inSign up

attachmentUpload.test.ts

771 lines25.0 KB

1import { EnvironmentId, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES } from "@t3tools/contracts";
2import * as Option from "effect/Option";
3import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
4​
5const mocks = vi.hoisted(() => ({
6 canOperate: true,
7 documentUri: "file:///documents",
8 createAssetUrl: vi.fn(),
9 createUploadUrl: Symbol("create-upload-url"),
10 executeAtomQuery: vi.fn(),
11 removeUpload: Symbol("remove-upload"),
12 preparedConnection: Symbol("prepared-connection"),
13 runAtomCommand: vi.fn(),
14 readAtom: vi.fn(),
15 upload: vi.fn(),
16 writeFile: vi.fn(),
17 deleteFile: vi.fn(),
18 readBase64: vi.fn(),
19}));
20​
21vi.mock("@t3tools/client-runtime/state/runtime", () => ({
22 createEnvironmentRpcCommand: () => Symbol("rpc-command"),
23 executeAtomQuery: mocks.executeAtomQuery,
24 runAtomCommand: mocks.runAtomCommand,
25 squashAtomCommandFailure: (result: { readonly error: unknown }) => result.error,
26}));
27​
28vi.mock("../state/atom-registry", () => ({
29 appAtomRegistry: { get: mocks.readAtom },
30}));
31​
32vi.mock("./composerAttachmentPreviewRetention", () => ({
33 retainComposerAttachmentFileForPreview: () => () => {},
34}));
35​
36vi.mock("../state/assets", () => ({
37 assetEnvironment: { createUrl: mocks.createAssetUrl },
38}));
39​
40vi.mock("../state/attachments", () => ({
41 attachmentEnvironment: {
42 createUploadUrl: mocks.createUploadUrl,
43 remove: mocks.removeUpload,
44 },
45}));
46​
47vi.mock("../state/session", () => ({
48 readEnvironmentScope: () => mocks.canOperate,
49 environmentSession: {
50 preparedConnectionValueAtom: () => mocks.preparedConnection,
51 },
52}));
53​
54// Cuts the expo-crypto -> react-native import chain out of the test graph.
55vi.mock("./uuid", () => ({
56 uuidv4: () => "uuid",
57 randomHex: () => "0000",
58}));
59​
60vi.mock("expo-file-system", () => ({
61 File: class {
62 readonly uri: string;
63 exists = true;
64 constructor(uri: string, name?: string) {
65 this.uri = name ? `${uri}/${name}` : uri;
66 }
67 create() {}
68 write(bytes: string, options: unknown) {
69 mocks.writeFile(this.uri, bytes, options);
70 }
71 delete() {
72 mocks.deleteFile(this.uri);
73 }
74​
75 async base64() {
76 return mocks.readBase64(this.uri);
77 }
78​
79 upload(url: string, options: unknown) {
80 return mocks.upload(this.uri, url, options);
81 }
82 },
83 Paths: {
84 cache: "file:///cache",
85 get document() {
86 return { uri: mocks.documentUri };
87 },
88 },
89 UploadType: { BINARY_CONTENT: 0 },
90}));
91​
92import {
93 prepareTurnAttachments,
94 releasePendingAttachmentUploads,
95 withUploadedMobileAttachmentReferences,
96} from "./attachmentUpload";
97import type { DraftComposerAttachment } from "./composerImages";
98​
99const environmentId = EnvironmentId.make("environment-1");
100const MINTED_ID = "pending-00000000-0000-4000-8000-000000000001-pdf";
101​
102const image = {
103 id: "image-1",
104 type: "image",
105 name: "screenshot.png",
106 mimeType: "image/png",
107 sizeBytes: 3,
108 dataUrl: "data:image/png;base64,YWJj",
109 previewUri: "file:///images/screenshot.png",
110} as const satisfies DraftComposerAttachment;
111​
112const fileBackedImage = {
113 id: "image-2",
114 type: "image",
115 name: "photo.png",
116 mimeType: "image/png",
117 sizeBytes: 3,
118 fileUri: "file:///documents/t3-composer-attachments/photo.png",
119 previewUri: "file:///documents/t3-composer-attachments/photo.png",
120} as const satisfies DraftComposerAttachment;
121​
122const file = {
123 id: "file-1",
124 type: "file",
125 name: "report.pdf",
126 mimeType: "application/pdf",
127 sizeBytes: 42,
128 fileUri: "file:///documents/report.pdf",
129} as const satisfies DraftComposerAttachment;
130​
131const documentPickedImage = {
132 id: "file-2",
133 type: "file",
134 name: "photo.png",
135 mimeType: "application/octet-stream",
136 sizeBytes: 3,
137 fileUri: "file:///documents/photo.png",
138} as const satisfies DraftComposerAttachment;
139​
140function removeCallsFor(attachmentId: string): number {
141 return mocks.runAtomCommand.mock.calls.filter(
142 ([, command, target]) =>
143 command === mocks.removeUpload &&
144 (target as { input: { attachmentId: string } }).input.attachmentId === attachmentId,
145 ).length;
147​
148describe("prepareTurnAttachments", () => {
149 beforeEach(() => {
150 mocks.canOperate = true;
151 mocks.documentUri = "file:///documents";
152 mocks.createAssetUrl.mockReset();
153 mocks.createAssetUrl.mockImplementation((target: unknown) => target);
154 mocks.executeAtomQuery.mockReset();
155 mocks.executeAtomQuery.mockResolvedValue({ _tag: "Success", value: {} });
156 mocks.runAtomCommand.mockReset();
157 mocks.readAtom.mockReset();
158 mocks.upload.mockReset();
159 mocks.writeFile.mockReset();
160 mocks.deleteFile.mockReset();
161 mocks.readBase64.mockReset();
162 mocks.readBase64.mockResolvedValue("YWJj");
163 mocks.readAtom.mockReturnValue(Option.some({ httpBaseUrl: "https://environment.example/" }));
164 mocks.runAtomCommand.mockImplementation(async (_registry: unknown, command: unknown) =>
165 command === mocks.createUploadUrl
166 ? {
167 _tag: "Success",
168 value: {
169 attachmentId: MINTED_ID,
170 relativeUrl: "/api/attachments/upload/signed",
171 expiresAt: 1,
172 },
173 }
174 : { _tag: "Success", value: undefined },
175 );
176 mocks.upload.mockResolvedValue({ status: 204, body: "", headers: {} });
177 });
178​
179 it("does not mint or transfer attachments without task operation access", async () => {
180 mocks.canOperate = false;
181 await expect(
182 prepareTurnAttachments({
183 environmentId,
184 attachments: [fileBackedImage],
185 supportsImageUploads: true,
186 }),
187 ).rejects.toThrow("cannot upload attachments");
188 expect(mocks.runAtomCommand).not.toHaveBeenCalled();
189 expect(mocks.upload).not.toHaveBeenCalled();
190 });
191​
192 it("rechecks access after a signed URL is minted before sending any bytes", async () => {
193 mocks.runAtomCommand.mockImplementationOnce(async () => {
194 mocks.canOperate = false;
195 return {
196 _tag: "Success",
197 value: {
198 attachmentId: MINTED_ID,
199 relativeUrl: "/api/attachments/upload/signed",
200 expiresAt: 1,
201 },
202 };
203 });
204 const warning = vi.spyOn(console, "warn").mockImplementation(() => {});
205 try {
206 await expect(
207 prepareTurnAttachments({
208 environmentId,
209 attachments: [fileBackedImage],
210 supportsImageUploads: true,
211 }),
212 ).rejects.toThrow("cannot upload attachments");
213 expect(mocks.upload).not.toHaveBeenCalled();
214 expect(mocks.runAtomCommand).toHaveBeenCalledTimes(1);
215 } finally {
216 warning.mockRestore();
217 }
218 });
219​
220 it("does not replace an expired upload when access was revoked during verification", async () => {
221 mocks.executeAtomQuery.mockImplementationOnce(async () => {
222 mocks.canOperate = false;
223 return { _tag: "Failure", error: { _tag: "AssetAttachmentNotFoundError" } };
224 });
225 await expect(
226 prepareTurnAttachments({
227 environmentId,
228 attachments: [
229 {
230 ...fileBackedImage,
231 uploadedAttachmentId: MINTED_ID,
232 uploadEnvironmentId: environmentId,
233 },
234 ],
235 supportsImageUploads: true,
236 }),
237 ).rejects.toThrow("cannot upload attachments");
238 expect(mocks.runAtomCommand).not.toHaveBeenCalled();
239 expect(mocks.upload).not.toHaveBeenCalled();
240 });
241​
242 it("rechecks deletion permission before retrying a failed cleanup", async () => {
243 mocks.runAtomCommand.mockImplementationOnce(async () => {
244 mocks.canOperate = false;
245 return { _tag: "Failure", error: new Error("Retry cleanup") };
246 });
247 await expect(releasePendingAttachmentUploads(environmentId, [MINTED_ID])).rejects.toThrow(
248 "cannot delete pending attachments",
249 );
250 expect(mocks.runAtomCommand).toHaveBeenCalledTimes(1);
251 });
252​
253 it("inlines a file-backed image lazily when the server lacks image uploads", async () => {
254 const prepared = await prepareTurnAttachments({
255 environmentId,
256 attachments: [fileBackedImage],
257 });
258​
259 expect(prepared.status).toBe("ready");
260 if (prepared.status !== "ready") return;
261 expect(prepared.attachments).toEqual([
262 {
263 type: "image",
264 name: "photo.png",
265 mimeType: "image/png",
266 sizeBytes: 3,
267 dataUrl: "data:image/png;base64,YWJj",
268 },
269 ]);
270 expect(mocks.readBase64).toHaveBeenCalledExactlyOnceWith(fileBackedImage.fileUri);
271 expect(mocks.upload).not.toHaveBeenCalled();
272 });
273​
274 // regression: #9713 read file-backed image drafts before enabling them
275 it("reads restored images from the current iOS document container for legacy sends", async () => {
276 const fileName = "33333333-3333-4333-8333-333333333333-photo.png";
277 mocks.documentUri =
278 "file:///var/mobile/Containers/Data/Application/22222222-2222-4222-8222-222222222222/Documents";
279 const oldUri = `file:///var/mobile/Containers/Data/Application/11111111-1111-4111-8111-111111111111/Documents/t3-composer-attachments/${fileName}`;
280 const prepared = await prepareTurnAttachments({
281 environmentId,
282 attachments: [{ ...fileBackedImage, fileUri: oldUri, previewUri: oldUri }],
283 });
284 expect(prepared.status).toBe("ready");
285 expect(mocks.readBase64).toHaveBeenCalledExactlyOnceWith(
286 `${mocks.documentUri}/t3-composer-attachments/${fileName}`,
287 );
288 });
289​
290 // regression: #9713 read file-backed image drafts before enabling them
291 it("rejects a legacy image without inline bytes or a file", async () => {
292 const { fileUri: _, ...missingImage } = fileBackedImage;
293 await expect(
294 prepareTurnAttachments({ environmentId, attachments: [missingImage] }),
295 ).rejects.toThrow("'photo.png' is no longer available. Attach the image again.");
296 });
297​
298 it.each(["resolve", "reject"] as const)(
299 "abandons a canceled legacy image read that later %ss",
300 async (outcome) => {
301 const readStarted = Promise.withResolvers<void>();
302 const read = Promise.withResolvers<string>();
303 const controller = new AbortController();
304 mocks.readBase64.mockImplementation(() => {
305 readStarted.resolve();
306 return read.promise;
307 });
308 const preparing = prepareTurnAttachments({
309 environmentId,
310 attachments: [fileBackedImage],
311 signal: controller.signal,
312 });
313 await readStarted.promise;
314 controller.abort();
315 if (outcome === "resolve") read.resolve("YWJj");
316 else read.reject(new Error("Native read failed after cancellation"));
317​
318 await expect(preparing).resolves.toEqual({ status: "abandoned" });
319 },
320 );
321​
322 // regression: #9713 read file-backed image drafts before enabling them
323 it("uploads a file-backed image from its owned copy without staging base64", async () => {
324 mocks.upload.mockImplementation(
325 async (
326 _uri: string,
327 _url: string,
328 options: { onProgress?: (event: { bytesSent: unknown; totalBytes: unknown }) => void },
329 ) => {
330 // expo-modules-jsi delivers native Int64 progress counts as BigInt.
331 options.onProgress?.({ bytesSent: BigInt(1), totalBytes: BigInt(4) });
332 return { status: 204, body: "", headers: {} };
333 },
334 );
335 const progress: Array<number> = [];
336 const prepared = await prepareTurnAttachments({
337 environmentId,
338 attachments: [fileBackedImage],
339 supportsImageUploads: true,
340 onUploadProgress: (_, value) => progress.push(value),
341 });
342​
343 expect(progress).toEqual([0.25]);
344​
345 expect(mocks.upload).toHaveBeenCalledWith(
346 fileBackedImage.fileUri,
347 "https://environment.example/api/attachments/upload/signed",
348 expect.objectContaining({ headers: { "Content-Type": "image/png" } }),
349 );
350 expect(mocks.readBase64).not.toHaveBeenCalled();
351 expect(mocks.writeFile).not.toHaveBeenCalled();
352 expect(mocks.deleteFile).not.toHaveBeenCalled();
353 expect(prepared.status).toBe("ready");
354 if (prepared.status !== "ready") return;
355 expect(prepared.attachments).toEqual([
356 {
357 type: "image",
358 id: MINTED_ID,
359 name: "photo.png",
360 mimeType: "image/png",
361 sizeBytes: 3,
362 },
363 ]);
364 });
365​
366 it("sends a document-picked picture with the mime it was uploaded under", async () => {
367 const prepared = await prepareTurnAttachments({
368 environmentId,
369 attachments: [documentPickedImage],
370 });
371​
372 expect(mocks.upload).toHaveBeenCalledWith(
373 "file:///documents/photo.png",
374 "https://environment.example/api/attachments/upload/signed",
375 expect.objectContaining({ headers: { "Content-Type": "image/png" } }),
376 );
377 expect(prepared.status).toBe("ready");
378 if (prepared.status !== "ready") return;
379 expect(prepared.attachments[0]).toEqual({
380 type: "image",
381 id: MINTED_ID,
382 name: "photo.png",
383 mimeType: "image/png",
384 sizeBytes: 3,
385 });
386 });
387​
388 it("uploads an oversized document-picked image as a file", async () => {
389 const largeImage = {
390 ...documentPickedImage,
391 mimeType: "image/png",
392 sizeBytes: PROVIDER_SEND_TURN_MAX_IMAGE_BYTES + 1,
393 };
394 const prepared = await prepareTurnAttachments({ environmentId, attachments: [largeImage] });
395​
396 expect(mocks.runAtomCommand).toHaveBeenCalledWith(
397 expect.anything(),
398 mocks.createUploadUrl,
399 expect.objectContaining({
400 input: {
401 type: "file",
402 name: largeImage.name,
403 mimeType: "image/png",
404 sizeBytes: largeImage.sizeBytes,
405 },
406 }),
407 expect.anything(),
408 );
409 expect(prepared.status).toBe("ready");
410 if (prepared.status !== "ready") return;
411 expect(prepared.attachments).toEqual([
412 {
413 type: "file",
414 id: MINTED_ID,
415 name: largeImage.name,
416 mimeType: "image/png",
417 sizeBytes: largeImage.sizeBytes,
418 },
419 ]);
420 expect(mocks.readBase64).not.toHaveBeenCalled();
421 });
422​
423 it("uploads generic file bytes directly and keeps mixed attachment order", async () => {
424 const pastedFile = {
425 ...file,
426 source: { _tag: "pasted-text" as const },
427 };
428 const prepared = await prepareTurnAttachments({
429 environmentId,
430 attachments: [pastedFile, image],
431 });
432​
433 expect(mocks.upload).toHaveBeenCalledWith(
434 "file:///documents/report.pdf",
435 "https://environment.example/api/attachments/upload/signed",
436 expect.objectContaining({
437 httpMethod: "POST",
438 uploadType: 0,
439 headers: { "Content-Type": "application/pdf" },
440 }),
441 );
442 expect(prepared.status).toBe("ready");
443 if (prepared.status !== "ready") return;
444 expect(prepared.attachments[0]).toEqual({
445 type: "file",
446 id: MINTED_ID,
447 name: "report.pdf",
448 mimeType: "application/pdf",
449 sizeBytes: 42,
450 source: { _tag: "pasted-text" },
451 });
452 expect(prepared.attachments[1]?.type).toBe("image");
453 expect(prepared.pendingAttachmentIds).toEqual([MINTED_ID]);
454 expect(prepared.draftAttachments[0]).toEqual({
455 ...pastedFile,
456 uploadedAttachmentId: MINTED_ID,
457 uploadEnvironmentId: environmentId,
458 });
459 });
460​
461 it("uses the current connection when an environment reconnects during URL creation", async () => {
462 mocks.readAtom
463 .mockReturnValueOnce(Option.some({ httpBaseUrl: "https://old-environment.example/" }))
464 .mockReturnValueOnce(Option.some({ httpBaseUrl: "https://new-environment.example/" }));
465​
466 await prepareTurnAttachments({ environmentId, attachments: [file] });
467​
468 expect(mocks.upload).toHaveBeenCalledWith(
469 file.fileUri,
470 "https://new-environment.example/api/attachments/upload/signed",
471 expect.anything(),
472 );
473 });
474​
475 it("uploads a restored draft file from the current iOS document container", async () => {
476 const fileName = "33333333-3333-4333-8333-333333333333-report%20%23.pdf";
477 const restoredFile = {
478 ...file,
479 fileUri: `file:///private/var/mobile/Containers/Data/Application/11111111-1111-4111-8111-111111111111/Documents/t3-composer-attachments/${fileName}`,
480 };
481 mocks.documentUri =
482 "file:///var/mobile/Containers/Data/Application/22222222-2222-4222-8222-222222222222/Documents";
483 const currentUri = `${mocks.documentUri}/t3-composer-attachments/${fileName}`;
484 mocks.upload.mockImplementation(async (uri: string) => {
485 if (uri !== currentUri) {
486 throw new Error("File does not exist in the previous application container.");
487 }
488 return { status: 204, body: "", headers: {} };
489 });
490​
491 const prepared = await prepareTurnAttachments({
492 environmentId,
493 attachments: [restoredFile],
494 });
495​
496 expect(prepared.status).toBe("ready");
497 expect(mocks.upload).toHaveBeenCalledWith(
498 currentUri,
499 "https://environment.example/api/attachments/upload/signed",
500 expect.anything(),
501 );
502 });
503​
504 it("adds uploaded file references to durable drafts without changing images", () => {
505 expect(
506 withUploadedMobileAttachmentReferences({
507 environmentId,
508 attachments: [file, image],
509 uploadedAttachments: [
510 {
511 type: "file",
512 id: "pending-existing-pdf",
513 name: file.name,
514 mimeType: file.mimeType,
515 sizeBytes: file.sizeBytes,
516 },
517 {
518 type: "image",
519 name: image.name,
520 mimeType: image.mimeType,
521 sizeBytes: image.sizeBytes,
522 dataUrl: image.dataUrl,
523 },
524 ],
525 }),
526 ).toEqual([
527 {
528 ...file,
529 uploadedAttachmentId: "pending-existing-pdf",
530 uploadEnvironmentId: environmentId,
531 },
532 image,
533 ]);
534 });
535​
536 it("reuses a pending file upload from a previous outbox attempt", async () => {
537 const previouslyUploaded = {
538 ...file,
539 uploadedAttachmentId: "pending-existing-pdf",
540 uploadEnvironmentId: environmentId,
541 };
542​
543 const prepared = await prepareTurnAttachments({
544 environmentId,
545 attachments: [previouslyUploaded, image],
546 });
547​
548 expect(prepared.status).toBe("ready");
549 if (prepared.status !== "ready") return;
550 expect(prepared.attachments).toEqual([
551 {
552 type: "file",
553 id: "pending-existing-pdf",
554 name: "report.pdf",
555 mimeType: "application/pdf",
556 sizeBytes: 42,
557 },
558 {
559 type: "image",
560 name: "screenshot.png",
561 mimeType: "image/png",
562 sizeBytes: 3,
563 dataUrl: "data:image/png;base64,YWJj",
564 },
565 ]);
566 expect(prepared.pendingAttachmentIds).toEqual(["pending-existing-pdf"]);
567 expect(mocks.upload).not.toHaveBeenCalled();
568 expect(mocks.runAtomCommand).not.toHaveBeenCalled();
569 });
570​
571 it("uploads a file again when its saved pending upload has expired", async () => {
572 mocks.executeAtomQuery.mockResolvedValueOnce({
573 _tag: "Failure",
574 error: { _tag: "AssetAttachmentNotFoundError" },
575 });
576 const previouslyUploaded = {
577 ...file,
578 uploadedAttachmentId: "pending-expired-pdf",
579 uploadEnvironmentId: environmentId,
580 };
581​
582 const prepared = await prepareTurnAttachments({
583 environmentId,
584 attachments: [previouslyUploaded],
585 });
586​
587 expect(mocks.upload).toHaveBeenCalledOnce();
588 expect(prepared.status).toBe("ready");
589 if (prepared.status !== "ready") return;
590 expect(prepared.pendingAttachmentIds).toEqual([MINTED_ID]);
591 });
592​
593 it("uploads image bytes over HTTP while retaining the durable offline image", async () => {
594 const persisted = vi.fn(async () => "persisted" as const);
595 const prepared = await prepareTurnAttachments({
596 environmentId,
597 attachments: [image],
598 supportsImageUploads: true,
599 persistUploadedReferences: persisted,
600 });
601 expect(mocks.writeFile).toHaveBeenCalledWith("file:///cache/t3-upload-uuid", "YWJj", {
602 encoding: "base64",
603 });
604 expect(mocks.upload).toHaveBeenCalledWith(
605 "file:///cache/t3-upload-uuid",
606 "https://environment.example/api/attachments/upload/signed",
607 expect.objectContaining({ headers: { "Content-Type": "image/png" } }),
608 );
609 expect(mocks.deleteFile).toHaveBeenCalledExactlyOnceWith("file:///cache/t3-upload-uuid");
610 expect(prepared.status).toBe("ready");
611 if (prepared.status !== "ready") return;
612 expect(prepared.attachments).toEqual([
613 {
614 type: "image",
615 id: MINTED_ID,
616 name: image.name,
617 mimeType: image.mimeType,
618 sizeBytes: image.sizeBytes,
619 },
620 ]);
621 expect(prepared.draftAttachments).toEqual([
622 { ...image, uploadedAttachmentId: MINTED_ID, uploadEnvironmentId: environmentId },
623 ]);
624 expect(persisted).toHaveBeenCalledWith(prepared.draftAttachments);
625 });
626​
627 it("reuses an uploaded image and reuploads its local bytes after server expiry", async () => {
628 const saved = {
629 ...image,
630 uploadedAttachmentId: "saved-image",
631 uploadEnvironmentId: environmentId,
632 };
633 const reused = await prepareTurnAttachments({
634 environmentId,
635 attachments: [saved],
636 supportsImageUploads: true,
637 });
638 expect(reused.status === "ready" && reused.attachments[0]).toEqual({
639 type: "image",
640 id: "saved-image",
641 name: image.name,
642 mimeType: image.mimeType,
643 sizeBytes: image.sizeBytes,
644 });
645 expect(mocks.upload).not.toHaveBeenCalled();
646 mocks.executeAtomQuery.mockResolvedValueOnce({
647 _tag: "Failure",
648 error: { _tag: "AssetAttachmentNotFoundError" },
649 });
650 const restored = await prepareTurnAttachments({
651 environmentId,
652 attachments: [saved],
653 supportsImageUploads: true,
654 });
655 expect(restored.status === "ready" && restored.draftAttachments[0]).toEqual({
656 ...saved,
657 uploadedAttachmentId: MINTED_ID,
658 });
659 expect(mocks.writeFile).toHaveBeenCalledWith("file:///cache/t3-upload-uuid", "YWJj", {
660 encoding: "base64",
661 });
662 });
663​
664 it("does not reuse an image upload from another environment", async () => {
665 const prepared = await prepareTurnAttachments({
666 environmentId,
667 attachments: [
668 {
669 ...image,
670 uploadedAttachmentId: "other-image",
671 uploadEnvironmentId: EnvironmentId.make("other"),
672 },
673 ],
674 supportsImageUploads: true,
675 });
676 expect(mocks.executeAtomQuery).not.toHaveBeenCalled();
677 expect(mocks.upload).toHaveBeenCalledOnce();
678 expect(prepared.status === "ready" && prepared.draftAttachments[0]?.uploadEnvironmentId).toBe(
679 environmentId,
680 );
681 });
682​
683 it("aborts an active transfer without dropping local bytes or stamping a partial upload", async () => {
684 const started = Promise.withResolvers<void>();
685 const controller = new AbortController();
686 const persist = vi.fn(async () => "persisted" as const);
687 mocks.upload.mockImplementation(
688 (_uri: string, _url: string, options: { signal: AbortSignal }) =>
689 new Promise((_, reject) => {
690 options.signal.addEventListener("abort", () => reject(new Error("cancelled")), {
691 once: true,
692 });
693 started.resolve();
694 }),
695 );
696 const preparing = prepareTurnAttachments({
697 environmentId,
698 attachments: [file],
699 signal: controller.signal,
700 persistUploadedReferences: persist,
701 });
702 await started.promise;
703 controller.abort();
704 expect(await preparing).toEqual({ status: "abandoned" });
705 expect(persist).not.toHaveBeenCalled();
706 expect(mocks.deleteFile).not.toHaveBeenCalled();
707 expect(removeCallsFor(MINTED_ID)).toBe(1);
708 });
709​
710 it("removes pending uploads when the native HTTP request fails", async () => {
711 mocks.upload.mockResolvedValue({ status: 500, body: "failed", headers: {} });
712​
713 await expect(prepareTurnAttachments({ environmentId, attachments: [file] })).rejects.toThrow(
714 "Upload failed for 'report.pdf' (500).",
715 );
716 expect(removeCallsFor(MINTED_ID)).toBe(1);
717 });
718​
719 it("keeps a previously persisted upload when a later attachment fails", async () => {
720 const previouslyUploaded = {
721 ...file,
722 id: "file-existing",
723 uploadedAttachmentId: "pending-existing-pdf",
724 uploadEnvironmentId: environmentId,
725 };
726 mocks.upload.mockResolvedValue({ status: 500, body: "failed", headers: {} });
727​
728 await expect(
729 prepareTurnAttachments({ environmentId, attachments: [previouslyUploaded, file] }),
730 ).rejects.toThrow("Upload failed for 'report.pdf' (500).");
731​
732 expect(removeCallsFor("pending-existing-pdf")).toBe(0);
733 });
734​
735 it("deletes the minted uploads when the owner abandons the send", async () => {
736 const result = await prepareTurnAttachments({
737 environmentId,
738 attachments: [file],
739 persistUploadedReferences: async () => "abandon",
740 });
741​
742 expect(result.status).toBe("abandoned");
743 expect(removeCallsFor(MINTED_ID)).toBe(1);
744 });
745});
746​
747describe("releasePendingAttachmentUploads", () => {
748 beforeEach(() => {
749 mocks.runAtomCommand.mockReset();
750 });
751​
752 it("throws when a delete keeps failing so the caller sees the leak", async () => {
753 mocks.runAtomCommand.mockResolvedValue({ _tag: "Failure", error: new Error("offline") });
754​
755 await expect(releasePendingAttachmentUploads(environmentId, ["pending-a"])).rejects.toThrow(
756 "pending-a",
757 );
758 });
759​
760 it("treats an already-deleted pending upload as released", async () => {
761 mocks.runAtomCommand.mockResolvedValue({
762 _tag: "Failure",
763 error: { _tag: "AssetAttachmentNotFoundError" },
764 });
765​
766 await expect(
767 releasePendingAttachmentUploads(environmentId, ["pending-a"]),
768 ).resolves.toBeUndefined();
769 expect(mocks.runAtomCommand).toHaveBeenCalledTimes(1);
770 });
771});