Skip to content
Source
Sign inSign up

attachments.test.ts

146 lines4.3 KB

1import { describe, expect, it } from "@effect/vitest";
2import {
3 EnvironmentId,
4 type AttachmentCreateUploadUrlInput,
5 type AttachmentCreateUploadUrlResult,
6 type AttachmentDeleteInput,
7} from "@t3tools/contracts";
8import * as Effect from "effect/Effect";
9import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";
10​
11import type { AtomCommand } from "./runtime.ts";
12import {
13 formatAttachmentSize,
14 runAttachmentUploadCycle,
15 verifyPersistedAttachmentUpload,
16} from "./attachments.ts";
17​
18const environmentId = EnvironmentId.make("environment-1");
19const registry = {} as AtomRegistry.AtomRegistry;
20​
21type CreateUploadUrlCommand = AtomCommand<
22 { readonly environmentId: EnvironmentId; readonly input: AttachmentCreateUploadUrlInput },
23 AttachmentCreateUploadUrlResult,
24 never
25>;
26​
27type RemoveCommand = AtomCommand<
28 { readonly environmentId: EnvironmentId; readonly input: AttachmentDeleteInput },
29 unknown,
30 never
31>;
32​
33function makeCreateUploadUrl(attachmentId: string): CreateUploadUrlCommand {
34 return {
35 label: "test:create-upload-url",
36 run: async () =>
37 AsyncResult.success<AttachmentCreateUploadUrlResult, never>({
38 attachmentId,
39 relativeUrl: `/api/attachments/upload/${attachmentId}`,
40 expiresAt: 1,
41 }),
42 };
44​
45const removeCalls: string[] = [];
46const remove: RemoveCommand = {
47 label: "test:remove",
48 run: async (_registry, input) => {
49 removeCalls.push(input.input.attachmentId);
50 return AsyncResult.success(undefined);
51 },
52};
53​
54const uploadInput: AttachmentCreateUploadUrlInput = {
55 type: "file",
56 name: "report.pdf",
57 mimeType: "application/pdf",
58 sizeBytes: 3,
59};
60​
61describe("runAttachmentUploadCycle", () => {
62 it("deletes the fresh mint when the caller cancels at onMinted", async () => {
63 removeCalls.length = 0;
64 const result = await runAttachmentUploadCycle({
65 registry,
66 createUploadUrl: makeCreateUploadUrl("pending-cancelled"),
67 remove,
68 environmentId,
69 upload: uploadInput,
70 resolveUploadUrl: () => "https://environment.test/upload",
71 transport: () => {
72 throw new Error("transport must not run after cancel");
73 },
74 onMinted: () => "cancel",
75 });
76​
77 expect(result).toEqual({ status: "cancelled", attachmentId: "pending-cancelled" });
78 expect(removeCalls).toEqual(["pending-cancelled"]);
79 });
80​
81 it("keeps the minted id on transfer failure so the caller can retry or release", async () => {
82 removeCalls.length = 0;
83 const result = await runAttachmentUploadCycle({
84 registry,
85 createUploadUrl: makeCreateUploadUrl("pending-failed"),
86 remove,
87 environmentId,
88 upload: uploadInput,
89 resolveUploadUrl: () => "https://environment.test/upload",
90 transport: () => ({
91 done: Promise.reject(new Error("Upload rejected (413)")),
92 abort: () => {},
93 }),
94 });
95​
96 expect(result).toMatchObject({
97 status: "failed",
98 step: "transfer",
99 attachmentId: "pending-failed",
100 });
101 expect(removeCalls).toEqual([]);
102 });
103});
104​
105describe("verifyPersistedAttachmentUpload", () => {
106 it("hits the server on every verification instead of reusing a cached failure", async () => {
107 let lookups = 0;
108 const assetUrlAtom = Atom.make(
109 Effect.promise(() => Promise.resolve()).pipe(
110 Effect.flatMap(() => {
111 lookups += 1;
112 return lookups === 1
113 ? Effect.fail({ _tag: "TransportError" } as const)
114 : Effect.succeed({ url: "/api/assets/pending-1" });
115 }),
116 ),
117 ).pipe(Atom.swr({ staleTime: 60_000 }), Atom.keepAlive);
118 const liveRegistry = AtomRegistry.make();
119​
120 const verify = () =>
121 verifyPersistedAttachmentUpload({
122 registry: liveRegistry,
123 createAssetUrl: () => assetUrlAtom,
124 environmentId,
125 attachmentId: "pending-1",
126 });
127​
128 const first = await verify();
129 expect(first).toMatchObject({ status: "failed" });
130​
131 const second = await verify();
132 expect(second).toEqual({ status: "verified" });
133 expect(lookups).toBe(2);
134 });
135});
136​
137describe("formatAttachmentSize", () => {
138 it.each([
139 [1_047_552, "1023 KB"],
140 [1_047_553, "1.0 MB"],
141 [1_048_575, "1.0 MB"],
142 [1_048_576, "1.0 MB"],
143 ])("formats %d bytes as %s", (sizeBytes, label) => {
144 expect(formatAttachmentSize(sizeBytes)).toBe(label);
145 });
146});