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