1import { describe, expect, it } from "@effect/vitest";
2import {
3 EnvironmentId,
4 PROVIDER_SEND_TURN_MAX_FILE_BYTES,
5 type AttachmentCreateUploadUrlInput,
6 type AttachmentCreateUploadUrlResult,
7 type AttachmentDeleteInput,
8} from "@t3tools/contracts";
9import * as Effect from "effect/Effect";
10import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";
11
12import type { AtomCommand } from "./runtime.ts";
13import {
14 clampFileAttachmentUploadBytes,
15 fileAttachmentTooLargeMessage,
16 formatAttachmentSize,
17 runAttachmentUploadCycle,
18 verifyPersistedAttachmentUpload,
19} from "./attachments.ts";
20
21const environmentId = EnvironmentId.make("environment-1");
22// The cycle threads the registry through to the commands untouched, so the
23// fakes below can ignore it.
24const registry = {} as AtomRegistry.AtomRegistry;
25
26type CreateUploadUrlCommand = AtomCommand<
27 { readonly environmentId: EnvironmentId; readonly input: AttachmentCreateUploadUrlInput },
28 AttachmentCreateUploadUrlResult,
29 never
30>;
31
32type RemoveCommand = AtomCommand<
33 { readonly environmentId: EnvironmentId; readonly input: AttachmentDeleteInput },
34 unknown,
35 never
36>;
37
38function makeCreateUploadUrl(attachmentId: string): CreateUploadUrlCommand {
39 return {
40 label: "test:create-upload-url",
41 run: async () =>
42 AsyncResult.success<AttachmentCreateUploadUrlResult, never>({
43 attachmentId,
44 relativeUrl: `/api/attachments/upload/${attachmentId}`,
45 expiresAt: 1,
46 }),
47 };
48}
49
50const removeCalls: string[] = [];
51const remove: RemoveCommand = {
52 label: "test:remove",
53 run: async (_registry, input) => {
54 removeCalls.push(input.input.attachmentId);
55 return AsyncResult.success(undefined);
56 },
57};
58
59const uploadInput: AttachmentCreateUploadUrlInput = {
60 type: "file",
61 name: "report.pdf",
62 mimeType: "application/pdf",
63 sizeBytes: 3,
64};
65
66describe("runAttachmentUploadCycle", () => {
67 it("mints, transfers, and reports the attachment id", async () => {
68 const transferred: string[] = [];
69 const result = await runAttachmentUploadCycle({
70 registry,
71 createUploadUrl: makeCreateUploadUrl("pending-1"),
72 remove,
73 environmentId,
74 upload: uploadInput,
75 resolveUploadUrl: (relativeUrl) => `https://environment.test${relativeUrl}`,
76 transport: (url) => {
77 transferred.push(url);
78 return { done: Promise.resolve(), abort: () => {} };
79 },
80 });
81
82 expect(result).toEqual({ status: "uploaded", attachmentId: "pending-1" });
83 expect(transferred).toEqual(["https://environment.test/api/attachments/upload/pending-1"]);
84 });
85
86 it("deletes the fresh mint when the caller cancels at onMinted", async () => {
87 removeCalls.length = 0;
88 const result = await runAttachmentUploadCycle({
89 registry,
90 createUploadUrl: makeCreateUploadUrl("pending-cancelled"),
91 remove,
92 environmentId,
93 upload: uploadInput,
94 resolveUploadUrl: () => "https://environment.test/upload",
95 transport: () => {
96 throw new Error("transport must not run after cancel");
97 },
98 onMinted: () => "cancel",
99 });
100
101 expect(result).toEqual({ status: "cancelled", attachmentId: "pending-cancelled" });
102 expect(removeCalls).toEqual(["pending-cancelled"]);
103 });
104
105 it("keeps the minted id on transfer failure so the caller can retry or release", async () => {
106 removeCalls.length = 0;
107 const result = await runAttachmentUploadCycle({
108 registry,
109 createUploadUrl: makeCreateUploadUrl("pending-failed"),
110 remove,
111 environmentId,
112 upload: uploadInput,
113 resolveUploadUrl: () => "https://environment.test/upload",
114 transport: () => ({
115 done: Promise.reject(new Error("Upload rejected (413)")),
116 abort: () => {},
117 }),
118 });
119
120 expect(result).toMatchObject({
121 status: "failed",
122 step: "transfer",
123 attachmentId: "pending-failed",
124 });
125 expect(removeCalls).toEqual([]);
126 });
127});
128
129describe("verifyPersistedAttachmentUpload", () => {
130 it("hits the server on every verification instead of reusing a cached failure", async () => {
131 // Mirrors the app's asset URL query atom: SWR-cached with a long stale
132 // window and kept alive across calls. Without a forced refresh, the
133 // second verification would read the cached failure and never retry.
134 let lookups = 0;
135 const assetUrlAtom = Atom.make(
136 // Async like the real RPC, so the first read is still in flight when
137 // the query decides whether a refresh is needed.
138 Effect.promise(() => Promise.resolve()).pipe(
139 Effect.flatMap(() => {
140 lookups += 1;
141 return lookups === 1
142 ? Effect.fail({ _tag: "TransportError" } as const)
143 : Effect.succeed({ url: "/api/assets/pending-1" });
144 }),
145 ),
146 ).pipe(Atom.swr({ staleTime: 60_000 }), Atom.keepAlive);
147 const liveRegistry = AtomRegistry.make();
148
149 const verify = () =>
150 verifyPersistedAttachmentUpload({
151 registry: liveRegistry,
152 createAssetUrl: () => assetUrlAtom,
153 environmentId,
154 attachmentId: "pending-1",
155 });
156
157 const first = await verify();
158 expect(first).toMatchObject({ status: "failed" });
159
160 const second = await verify();
161 expect(second).toEqual({ status: "verified" });
162 expect(lookups).toBe(2);
163 });
164});
165
166describe("file attachment limits", () => {
167 it("clamps the advertised limit to the turn contract cap", () => {
168 expect(clampFileAttachmentUploadBytes(1024)).toBe(1024);
169 expect(clampFileAttachmentUploadBytes(PROVIDER_SEND_TURN_MAX_FILE_BYTES * 2)).toBe(
170 PROVIDER_SEND_TURN_MAX_FILE_BYTES,
171 );
172 });
173
174 it("formats attachment row sizes", () => {
175 expect(formatAttachmentSize(3 * 1024 * 1024)).toBe("3.0 MB");
176 expect(formatAttachmentSize(1)).toBe("1 KB");
177 });
178
179 it("formats small upload limits without rounding them to zero MB", () => {
180 expect(fileAttachmentTooLargeMessage("tiny.txt", 1)).toBe(
181 "'tiny.txt' exceeds the 1 byte attachment limit.",
182 );
183 expect(fileAttachmentTooLargeMessage("small.txt", 1024)).toBe(
184 "'small.txt' exceeds the 1 KB attachment limit.",
185 );
186 expect(fileAttachmentTooLargeMessage("exact.txt", 1025)).toBe(
187 "'exact.txt' exceeds the 1025 bytes attachment limit.",
188 );
189 expect(fileAttachmentTooLargeMessage("medium.zip", 512 * 1024)).toBe(
190 "'medium.zip' exceeds the 512 KB attachment limit.",
191 );
192 });
193
194 it("keeps whole-MB upload limits for standard server caps", () => {
195 expect(fileAttachmentTooLargeMessage("one.bin", 1024 * 1024)).toBe(
196 "'one.bin' exceeds the 1 MB attachment limit.",
197 );
198 expect(fileAttachmentTooLargeMessage("big.zip", 50 * 1024 * 1024)).toBe(
199 "'big.zip' exceeds the 50 MB attachment limit.",
200 );
201 });
202});