import { ApprovalRequestId, EnvironmentId, ThreadId } from "@t3tools/contracts"; import { describe, expect, it, vi } from "vite-plus/test"; import { questionAttachmentDraftKey, questionAttachmentDraftPrefix, } from "../state/question-attachments"; vi.mock("../state/atom-registry", () => ({ appAtomRegistry: {} })); import { composerAttachmentUploadBlockReason, composerAttachmentsStillUploading, composerAttachmentUploadKey, composerDraftEnvironmentId, createComposerAttachmentUploadQueue, type ComposerAttachmentUploadRequest, type ComposerAttachmentUploadState, } from "./composerAttachmentUploadQueue"; const environmentId = EnvironmentId.make("environment-1"); function request(id: string, environment = environmentId): ComposerAttachmentUploadRequest { return { environmentId: environment, attachment: { id, type: "file", name: `${id}.pdf`, mimeType: "application/pdf", sizeBytes: 42, fileUri: `file:///documents/${id}.pdf`, }, }; } describe("composer attachment upload queue", () => { it("bounds concurrency, deduplicates updates, and drains all attachments", async () => { const gates = new Map>>(); const fourthStarted = Promise.withResolvers(); const firstThreeStarted = Promise.withResolvers(); let active = 0; let maximum = 0; const upload = vi.fn(async (input: ComposerAttachmentUploadRequest) => { active += 1; maximum = Math.max(maximum, active); const gate = Promise.withResolvers(); gates.set(input.attachment.id, gate); if (gates.size === 3) firstThreeStarted.resolve(); if (gates.size === 4) fourthStarted.resolve(); try { return await gate.promise; } finally { active -= 1; } }); const queue = createComposerAttachmentUploadQueue({ upload, onChange: () => {} }); const requests = [request("one"), request("two"), request("three"), request("four")]; queue.sync(requests); queue.sync(requests); await firstThreeStarted.promise; expect(upload).toHaveBeenCalledTimes(3); gates.get("one")!.resolve(true); await fourthStarted.promise; for (const gate of gates.values()) gate.resolve(true); await queue.settled(); queue.sync(requests); await queue.settled(); expect(maximum).toBe(3); expect(upload).toHaveBeenCalledTimes(4); queue.dispose(); }); it("cancels on disconnect and resumes from the same local draft on reconnect", async () => { const started = Promise.withResolvers(); let states: Readonly> = {}; let signal: AbortSignal | undefined; const upload = vi.fn( async (_request: ComposerAttachmentUploadRequest, currentSignal: AbortSignal) => { signal = currentSignal; started.resolve(); return new Promise((resolve) => currentSignal.addEventListener("abort", () => resolve(false), { once: true }), ); }, ); const queue = createComposerAttachmentUploadQueue({ upload, onChange: (next) => { states = next; }, }); const local = request("offline-draft"); queue.sync([local]); await started.promise; queue.sync([]); await queue.settled(); expect(signal?.aborted).toBe(true); expect(states).toEqual({}); upload.mockResolvedValueOnce(true); queue.sync([local]); await queue.settled(); expect(upload.mock.calls[1]?.[0]).toBe(local); expect(states[composerAttachmentUploadKey(environmentId, local.attachment.id)]).toEqual({ status: "ready", }); expect(local.attachment).toMatchObject({ fileUri: "file:///documents/offline-draft.pdf" }); queue.dispose(); }); it("ignores a late completion after removal or environment switch", async () => { const gate = Promise.withResolvers(); const started = Promise.withResolvers(); let states: Readonly> = {}; const upload = vi.fn(async () => { started.resolve(); return gate.promise; }); const queue = createComposerAttachmentUploadQueue({ upload, onChange: (next) => { states = next; }, }); queue.sync([request("photo")]); await started.promise; upload.mockResolvedValueOnce(true); const other = EnvironmentId.make("environment-2"); queue.sync([request("photo", other)]); gate.resolve(true); await queue.settled(); expect(states).toEqual({ [composerAttachmentUploadKey(other, "photo")]: { status: "ready" } }); queue.sync([]); expect(states).toEqual({}); queue.dispose(); }); it("restarts a re-added attachment after its aborted transfer finishes settling", async () => { const firstStarted = Promise.withResolvers(); const firstSettled = Promise.withResolvers(); const secondStarted = Promise.withResolvers(); const secondSettled = Promise.withResolvers(); let states: Readonly> = {}; let firstSignal: AbortSignal | undefined; const upload = vi.fn(async (_request: ComposerAttachmentUploadRequest, signal: AbortSignal) => { if (!firstSignal) { firstSignal = signal; firstStarted.resolve(); return firstSettled.promise; } secondStarted.resolve(); return secondSettled.promise; }); const queue = createComposerAttachmentUploadQueue({ upload, onChange: (next) => { states = next; }, }); const local = request("re-added"); queue.sync([local]); await firstStarted.promise; queue.sync([]); queue.sync([local]); expect(firstSignal?.aborted).toBe(true); expect(upload).toHaveBeenCalledOnce(); firstSettled.resolve(false); await secondStarted.promise; expect(upload).toHaveBeenCalledTimes(2); secondSettled.resolve(true); await queue.settled(); expect(states[composerAttachmentUploadKey(environmentId, local.attachment.id)]).toEqual({ status: "ready", }); queue.dispose(); }); it("keeps failures stable until retry and reports bounded progress", async () => { let states: Readonly> = {}; const progress: number[] = []; const upload = vi.fn( async ( _request: ComposerAttachmentUploadRequest, _signal: AbortSignal, report: (value: number) => void, ): Promise => { report(0.12); report(0.13); report(1.1); throw new Error("Server unavailable"); }, ); const queue = createComposerAttachmentUploadQueue({ upload, onChange: (next) => { states = next; const state = next[composerAttachmentUploadKey(environmentId, "file")]; if (state?.status === "uploading") progress.push(state.progress); }, }); queue.sync([request("file")]); await queue.settled(); queue.sync([request("file")]); expect(upload).toHaveBeenCalledOnce(); expect(states[composerAttachmentUploadKey(environmentId, "file")]).toEqual({ status: "failed", reason: "Server unavailable", }); expect(progress).toEqual([0, 0.1, 1]); upload.mockImplementationOnce(async () => true); queue.retry(environmentId, "file"); await queue.settled(); expect(states[composerAttachmentUploadKey(environmentId, "file")]).toEqual({ status: "ready" }); queue.dispose(); }); it("does not spin when an upload's draft was abandoned before persistence", async () => { const upload = vi.fn(async () => false); const queue = createComposerAttachmentUploadQueue({ upload, onChange: () => {} }); queue.sync([request("discarded")]); await queue.settled(); expect(upload).toHaveBeenCalledOnce(); queue.dispose(); }); }); describe("draft upload scope and offline submission", () => { it("uploads question drafts in their environment without matching other thread prefixes", () => { const requestId = ApprovalRequestId.make("request:1"); for (const environment of [environmentId, EnvironmentId.make("remote:server")]) { const threadId = ThreadId.make("thread:1"); const prefix = questionAttachmentDraftPrefix(environment, threadId); const key = questionAttachmentDraftKey(environment, threadId, requestId, "question:1"); expect(composerDraftEnvironmentId(key, [])).toBe(environment); expect(key.startsWith(prefix)).toBe(true); for (const suffix of ["-extra", ":extra", "/extra", "%extra"]) { const otherKey = questionAttachmentDraftKey( environment, ThreadId.make(`${threadId}${suffix}`), requestId, "question:1", ); expect(otherKey.startsWith(prefix)).toBe(false); expect(composerDraftEnvironmentId(otherKey, [])).toBe(environment); } } }); it("resolves thread, new-task, and queued-task drafts without crossing environments", () => { expect(composerDraftEnvironmentId("environment-1:thread", [])).toBe(environmentId); expect(composerDraftEnvironmentId("new-task:environment-1:project", [])).toBe(environmentId); expect( composerDraftEnvironmentId("pending-task:message", [{ messageId: "message", environmentId }]), ).toBe(environmentId); expect(composerDraftEnvironmentId("pending-task:missing", [])).toBeNull(); const colonEnvironment = EnvironmentId.make("a:vcs-status:b"); expect(composerDraftEnvironmentId(`${colonEnvironment}:thread`, [])).toBe(colonEnvironment); expect(composerDraftEnvironmentId(`new-task:${colonEnvironment}:project`, [])).toBe( colonEnvironment, ); }); it("reads an id-keyed new-task draft's environment from its project stamp", () => { const stamped = { project: { environmentId, projectId: "project" as never, createdAt: "2026-09-05T00:00:00.000Z", }, }; expect(composerDraftEnvironmentId("new-task:abc123-def456", [], stamped)).toBe(environmentId); // An id-keyed draft that lost its stamp belongs to nobody: uploads must // not start and sign-out must not sweep it into some other environment. expect(composerDraftEnvironmentId("new-task:abc123-def456", [])).toBeNull(); // The stamp wins over a legacy-looking key when both are present. expect(composerDraftEnvironmentId("new-task:environment-2:project", [], stamped)).toBe( environmentId, ); }); it("only a failed upload blocks sending; an in-flight one queues instead", () => { const key = composerAttachmentUploadKey(environmentId, "file"); const input = { environmentId, attachments: [request("file").attachment], connected: true, serverConfig: { environment: { capabilities: { attachmentUploads: true, fileAttachments: { maxUploadBytes: 1024 } }, }, }, states: {}, }; // Not started yet and mid-transfer both let the send through as a queued // message; the outbox drain finishes (or redoes) the upload. expect(composerAttachmentUploadBlockReason(input)).toBeNull(); expect(composerAttachmentsStillUploading(input)).toBe(true); expect( composerAttachmentsStillUploading({ ...input, states: { [key]: { status: "uploading", progress: 0.6 } }, }), ).toBe(true); expect(composerAttachmentUploadBlockReason({ ...input, connected: false })).toBeNull(); expect( composerAttachmentUploadBlockReason({ ...input, states: { [key]: { status: "failed", reason: "Offline" } }, }), ).toBe("Retry or remove the failed attachment"); expect( composerAttachmentsStillUploading({ ...input, states: { [key]: { status: "failed", reason: "Offline" } }, }), ).toBe(false); expect( composerAttachmentUploadBlockReason({ ...input, states: { [key]: { status: "ready" } } }), ).toBeNull(); expect( composerAttachmentsStillUploading({ ...input, states: { [key]: { status: "ready" } } }), ).toBe(false); // Attachments the environment cannot accept never count as uploading. expect(composerAttachmentsStillUploading({ ...input, serverConfig: null })).toBe(false); }); });