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(); readonly listeners = new Map 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((resolve) => { resolveMint = resolve; }); let resolveDelete: () => void = () => {}; const deleted = new Promise((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]); }); });