202 lines · 6.6 KB · TypeScript
RawDownload
import { describe, expect, it } from "@effect/vitest";import {  EnvironmentId,  PROVIDER_SEND_TURN_MAX_FILE_BYTES,  type AttachmentCreateUploadUrlInput,  type AttachmentCreateUploadUrlResult,  type AttachmentDeleteInput,} from "@t3tools/contracts";import * as Effect from "effect/Effect";import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; import type { AtomCommand } from "./runtime.ts";import {  clampFileAttachmentUploadBytes,  fileAttachmentTooLargeMessage,  formatAttachmentSize,  runAttachmentUploadCycle,  verifyPersistedAttachmentUpload,} from "./attachments.ts"; const environmentId = EnvironmentId.make("environment-1");// The cycle threads the registry through to the commands untouched, so the// fakes below can ignore it.const registry = {} as AtomRegistry.AtomRegistry; type CreateUploadUrlCommand = AtomCommand<  { readonly environmentId: EnvironmentId; readonly input: AttachmentCreateUploadUrlInput },  AttachmentCreateUploadUrlResult,  never>; type RemoveCommand = AtomCommand<  { readonly environmentId: EnvironmentId; readonly input: AttachmentDeleteInput },  unknown,  never>; function makeCreateUploadUrl(attachmentId: string): CreateUploadUrlCommand {  return {    label: "test:create-upload-url",    run: async () =>      AsyncResult.success<AttachmentCreateUploadUrlResult, never>({        attachmentId,        relativeUrl: `/api/attachments/upload/${attachmentId}`,        expiresAt: 1,      }),  };} const removeCalls: string[] = [];const remove: RemoveCommand = {  label: "test:remove",  run: async (_registry, input) => {    removeCalls.push(input.input.attachmentId);    return AsyncResult.success(undefined);  },}; const uploadInput: AttachmentCreateUploadUrlInput = {  type: "file",  name: "report.pdf",  mimeType: "application/pdf",  sizeBytes: 3,}; describe("runAttachmentUploadCycle", () => {  it("mints, transfers, and reports the attachment id", async () => {    const transferred: string[] = [];    const result = await runAttachmentUploadCycle({      registry,      createUploadUrl: makeCreateUploadUrl("pending-1"),      remove,      environmentId,      upload: uploadInput,      resolveUploadUrl: (relativeUrl) => `https://environment.test${relativeUrl}`,      transport: (url) => {        transferred.push(url);        return { done: Promise.resolve(), abort: () => {} };      },    });     expect(result).toEqual({ status: "uploaded", attachmentId: "pending-1" });    expect(transferred).toEqual(["https://environment.test/api/attachments/upload/pending-1"]);  });   it("deletes the fresh mint when the caller cancels at onMinted", async () => {    removeCalls.length = 0;    const result = await runAttachmentUploadCycle({      registry,      createUploadUrl: makeCreateUploadUrl("pending-cancelled"),      remove,      environmentId,      upload: uploadInput,      resolveUploadUrl: () => "https://environment.test/upload",      transport: () => {        throw new Error("transport must not run after cancel");      },      onMinted: () => "cancel",    });     expect(result).toEqual({ status: "cancelled", attachmentId: "pending-cancelled" });    expect(removeCalls).toEqual(["pending-cancelled"]);  });   it("keeps the minted id on transfer failure so the caller can retry or release", async () => {    removeCalls.length = 0;    const result = await runAttachmentUploadCycle({      registry,      createUploadUrl: makeCreateUploadUrl("pending-failed"),      remove,      environmentId,      upload: uploadInput,      resolveUploadUrl: () => "https://environment.test/upload",      transport: () => ({        done: Promise.reject(new Error("Upload rejected (413)")),        abort: () => {},      }),    });     expect(result).toMatchObject({      status: "failed",      step: "transfer",      attachmentId: "pending-failed",    });    expect(removeCalls).toEqual([]);  });}); describe("verifyPersistedAttachmentUpload", () => {  it("hits the server on every verification instead of reusing a cached failure", async () => {    // Mirrors the app's asset URL query atom: SWR-cached with a long stale    // window and kept alive across calls. Without a forced refresh, the    // second verification would read the cached failure and never retry.    let lookups = 0;    const assetUrlAtom = Atom.make(      // Async like the real RPC, so the first read is still in flight when      // the query decides whether a refresh is needed.      Effect.promise(() => Promise.resolve()).pipe(        Effect.flatMap(() => {          lookups += 1;          return lookups === 1            ? Effect.fail({ _tag: "TransportError" } as const)            : Effect.succeed({ url: "/api/assets/pending-1" });        }),      ),    ).pipe(Atom.swr({ staleTime: 60_000 }), Atom.keepAlive);    const liveRegistry = AtomRegistry.make();     const verify = () =>      verifyPersistedAttachmentUpload({        registry: liveRegistry,        createAssetUrl: () => assetUrlAtom,        environmentId,        attachmentId: "pending-1",      });     const first = await verify();    expect(first).toMatchObject({ status: "failed" });     const second = await verify();    expect(second).toEqual({ status: "verified" });    expect(lookups).toBe(2);  });}); describe("file attachment limits", () => {  it("clamps the advertised limit to the turn contract cap", () => {    expect(clampFileAttachmentUploadBytes(1024)).toBe(1024);    expect(clampFileAttachmentUploadBytes(PROVIDER_SEND_TURN_MAX_FILE_BYTES * 2)).toBe(      PROVIDER_SEND_TURN_MAX_FILE_BYTES,    );  });   it("formats attachment row sizes", () => {    expect(formatAttachmentSize(3 * 1024 * 1024)).toBe("3.0 MB");    expect(formatAttachmentSize(1)).toBe("1 KB");  });   it("formats small upload limits without rounding them to zero MB", () => {    expect(fileAttachmentTooLargeMessage("tiny.txt", 1)).toBe(      "'tiny.txt' exceeds the 1 byte attachment limit.",    );    expect(fileAttachmentTooLargeMessage("small.txt", 1024)).toBe(      "'small.txt' exceeds the 1 KB attachment limit.",    );    expect(fileAttachmentTooLargeMessage("exact.txt", 1025)).toBe(      "'exact.txt' exceeds the 1025 bytes attachment limit.",    );    expect(fileAttachmentTooLargeMessage("medium.zip", 512 * 1024)).toBe(      "'medium.zip' exceeds the 512 KB attachment limit.",    );  });   it("keeps whole-MB upload limits for standard server caps", () => {    expect(fileAttachmentTooLargeMessage("one.bin", 1024 * 1024)).toBe(      "'one.bin' exceeds the 1 MB attachment limit.",    );    expect(fileAttachmentTooLargeMessage("big.zip", 50 * 1024 * 1024)).toBe(      "'big.zip' exceeds the 50 MB attachment limit.",    );  });});