306 lines · 9.3 KB · TypeScript
RawDownload
import { EnvironmentId } from "@t3tools/contracts";import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import type { ComposerImageAttachment } from "../composerDraftStore"; const mocks = vi.hoisted(() => ({  createUploadUrl: Symbol("create-upload-url"),  removeUpload: Symbol("remove-upload"),  runAtomCommand: vi.fn(),  readPreparedConnection: vi.fn(),})); vi.mock("@t3tools/client-runtime/state/runtime", () => ({  runAtomCommand: mocks.runAtomCommand,})); vi.mock("../rpc/atomRegistry", () => ({ appAtomRegistry: {} })); vi.mock("../state/attachments", () => ({  attachmentEnvironment: {    createUploadUrl: mocks.createUploadUrl,    remove: mocks.removeUpload,  },})); vi.mock("../state/session", () => ({  readPreparedConnection: mocks.readPreparedConnection,})); import {  awaitAttachmentUploads,  getUploadedAttachments,  readAttachmentUpload,  releaseAttachmentUpload,  releaseAttachmentUploads,  retryAttachmentUpload,  startAttachmentUpload,  useAttachmentUploadStore,} from "./attachmentUploadQueue"; type ProgressListener = (event: {  readonly lengthComputable: boolean;  readonly loaded: number;  readonly total: number;}) => void; class TestXmlHttpRequest {  static requests: TestXmlHttpRequest[] = [];   status = 0;  timeout = 0;  method: string | null = null;  url: string | null = null;  readonly headers = new Map<string, string>();  readonly listeners = new Map<string, () => void>();  progressListener: ProgressListener | null = null;   readonly upload = {    addEventListener: (_event: string, listener: ProgressListener) => {      this.progressListener = listener;    },  };   constructor() {    TestXmlHttpRequest.requests.push(this);  }   open(method: string, url: string): void {    this.method = method;    this.url = url;  }   setRequestHeader(name: string, value: string): void {    this.headers.set(name, value);  }   addEventListener(event: string, listener: () => void): void {    this.listeners.set(event, listener);  }   send(): void {}   abort(): void {    this.listeners.get("abort")?.();  }   progress(loaded: number, total: number): void {    this.progressListener?.({ lengthComputable: true, loaded, total });  }   complete(status = 204): void {    this.status = status;    this.listeners.get("load")?.();  }} const firstEnvironment = EnvironmentId.make("environment-1");const secondEnvironment = EnvironmentId.make("environment-2"); function makeImage(id: string): ComposerImageAttachment {  const file = new File([new Uint8Array([1, 2, 3])], `${id}.png`, { type: "image/png" });  return {    type: "image",    id,    name: file.name,    mimeType: file.type,    sizeBytes: file.size,    previewUrl: `blob:${id}`,    file,  };} describe("attachmentUploadQueue", () => {  beforeEach(() => {    TestXmlHttpRequest.requests = [];    mocks.runAtomCommand.mockReset();    mocks.readPreparedConnection.mockReset();    mocks.readPreparedConnection.mockReturnValue({ httpBaseUrl: "https://environment.test/" });    mocks.runAtomCommand.mockImplementation(      async (        _registry: unknown,        command: unknown,        target: {          readonly environmentId: EnvironmentId;          readonly input: { readonly name?: string };        },      ) => {        if (command === mocks.createUploadUrl) {          const attachmentId = `pending-${target.environmentId}-${target.input.name}`;          return {            _tag: "Success",            value: {              attachmentId,              relativeUrl: `/api/attachments/upload/${attachmentId}`,              expiresAt: 1,            },          };        }        return { _tag: "Success", value: undefined };      },    );    vi.stubGlobal("XMLHttpRequest", TestXmlHttpRequest);  });   afterEach(() => {    for (const imageId of Object.keys(useAttachmentUploadStore.getState().uploadsByImageId)) {      releaseAttachmentUpload(imageId);    }    vi.unstubAllGlobals();  });   it("uploads images immediately and sends attachment references", async () => {    const image = makeImage("image-1");    startAttachmentUpload({ environmentId: firstEnvironment, image });    await Promise.resolve();     const request = TestXmlHttpRequest.requests[0]!;    expect(request.method).toBe("POST");    expect(request.url).toBe(      "https://environment.test/api/attachments/upload/pending-environment-1-image-1.png",    );    request.progress(1, 3);    expect(readAttachmentUpload(image.id)).toMatchObject({ status: "uploading", progress: 1 / 3 });     const settled = awaitAttachmentUploads([image.id]);    request.complete();    await settled;     expect(getUploadedAttachments({ environmentId: firstEnvironment, images: [image] })).toEqual([      {        type: "image",        id: "pending-environment-1-image-1.png",        name: "image-1.png",        mimeType: "image/png",        sizeBytes: 3,      },    ]);     releaseAttachmentUploads([image]);    expect(readAttachmentUpload(image.id)).toBeUndefined();    expect(mocks.runAtomCommand).toHaveBeenCalledWith(      expect.anything(),      mocks.removeUpload,      {        environmentId: firstEnvironment,        input: { attachmentId: "pending-environment-1-image-1.png" },      },      expect.anything(),    );  });   it("retries rejected uploads", async () => {    const image = makeImage("image-retry");    startAttachmentUpload({ environmentId: firstEnvironment, image });    await Promise.resolve();     let settled = awaitAttachmentUploads([image.id]);    TestXmlHttpRequest.requests[0]!.complete(500);    await settled;    expect(readAttachmentUpload(image.id)).toMatchObject({      status: "failed",      reason: "Upload rejected (500)",    });     retryAttachmentUpload({ environmentId: firstEnvironment, image });    await Promise.resolve();    settled = awaitAttachmentUploads([image.id]);    TestXmlHttpRequest.requests[1]!.complete();    await settled;     expect(readAttachmentUpload(image.id)).toMatchObject({ status: "ready" });  });   it("releases an upload URL that resolves after its image was removed", async () => {    const image = makeImage("image-cancelled");    const minted = {      _tag: "Success" as const,      value: {        attachmentId: "pending-environment-1-image-cancelled.png",        relativeUrl: "/api/attachments/upload/cancelled",        expiresAt: 1,      },    };    let resolveMint: (result: typeof minted) => void = () => {};    const pendingMint = new Promise<typeof minted>((resolve) => {      resolveMint = resolve;    });    let resolveDelete: () => void = () => {};    const deleted = new Promise<void>((resolve) => {      resolveDelete = resolve;    });    mocks.runAtomCommand.mockImplementation((_registry: unknown, command: unknown) => {      if (command === mocks.createUploadUrl) {        return pendingMint;      }      resolveDelete();      return Promise.resolve({ _tag: "Success", value: undefined });    });     startAttachmentUpload({ environmentId: firstEnvironment, image });    releaseAttachmentUpload(image.id);    resolveMint(minted);    await deleted;     expect(TestXmlHttpRequest.requests).toEqual([]);    expect(readAttachmentUpload(image.id)).toBeUndefined();    expect(mocks.runAtomCommand).toHaveBeenCalledWith(      expect.anything(),      mocks.removeUpload,      {        environmentId: firstEnvironment,        input: { attachmentId: minted.value.attachmentId },      },      expect.anything(),    );  });   it("restores the previous environment after a replacement upload fails", async () => {    const image = makeImage("image-move");    startAttachmentUpload({ environmentId: firstEnvironment, image });    await Promise.resolve();    let settled = awaitAttachmentUploads([image.id]);    TestXmlHttpRequest.requests[0]!.complete();    await settled;     startAttachmentUpload({ environmentId: secondEnvironment, image });    await Promise.resolve();    settled = awaitAttachmentUploads([image.id]);    TestXmlHttpRequest.requests[1]!.complete(500);    await settled;     startAttachmentUpload({ environmentId: firstEnvironment, image });    expect(readAttachmentUpload(image.id)).toMatchObject({      status: "ready",      environmentId: firstEnvironment,      attachmentId: "pending-environment-1-image-move.png",    });  });   it("does not let stalled uploads block another environment", async () => {    const images = ["image-a", "image-b", "image-c", "image-d"].map(makeImage);    for (const image of images) {      startAttachmentUpload({ environmentId: firstEnvironment, image });    }    const otherEnvironmentImage = makeImage("image-other");    startAttachmentUpload({ environmentId: secondEnvironment, image: otherEnvironmentImage });    await Promise.resolve();     expect(TestXmlHttpRequest.requests).toHaveLength(4);    const otherRequest = TestXmlHttpRequest.requests.find((request) =>      request.url?.includes("environment-2"),    );    expect(otherRequest).toBeDefined();     for (const request of TestXmlHttpRequest.requests) {      request.complete();    }    await Promise.all([      ...images.slice(0, 3).map((image) => awaitAttachmentUploads([image.id])),      awaitAttachmentUploads([otherEnvironmentImage.id]),    ]);    await Promise.resolve();    TestXmlHttpRequest.requests[4]!.complete();    await awaitAttachmentUploads([images[3]!.id]);  });});