| 1 | import { ApprovalRequestId, EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 2 | import { describe, expect, it, vi } from "vite-plus/test"; |
| 3 | import { |
| 4 | questionAttachmentDraftKey, |
| 5 | questionAttachmentDraftPrefix, |
| 6 | } from "../state/question-attachments"; |
| 7 | vi.mock("../state/atom-registry", () => ({ appAtomRegistry: {} })); |
| 8 | |
| 9 | import { |
| 10 | composerAttachmentUploadBlockReason, |
| 11 | composerAttachmentsStillUploading, |
| 12 | composerAttachmentUploadKey, |
| 13 | composerDraftEnvironmentId, |
| 14 | createComposerAttachmentUploadQueue, |
| 15 | type ComposerAttachmentUploadRequest, |
| 16 | type ComposerAttachmentUploadState, |
| 17 | } from "./composerAttachmentUploadQueue"; |
| 18 | |
| 19 | const environmentId = EnvironmentId.make("environment-1"); |
| 20 | function request(id: string, environment = environmentId): ComposerAttachmentUploadRequest { |
| 21 | return { |
| 22 | environmentId: environment, |
| 23 | attachment: { |
| 24 | id, |
| 25 | type: "file", |
| 26 | name: `${id}.pdf`, |
| 27 | mimeType: "application/pdf", |
| 28 | sizeBytes: 42, |
| 29 | fileUri: `file:///documents/${id}.pdf`, |
| 30 | }, |
| 31 | }; |
| 32 | } |
| 33 | |
| 34 | describe("composer attachment upload queue", () => { |
| 35 | it("bounds concurrency, deduplicates updates, and drains all attachments", async () => { |
| 36 | const gates = new Map<string, ReturnType<typeof Promise.withResolvers<boolean>>>(); |
| 37 | const fourthStarted = Promise.withResolvers<void>(); |
| 38 | const firstThreeStarted = Promise.withResolvers<void>(); |
| 39 | let active = 0; |
| 40 | let maximum = 0; |
| 41 | const upload = vi.fn(async (input: ComposerAttachmentUploadRequest) => { |
| 42 | active += 1; |
| 43 | maximum = Math.max(maximum, active); |
| 44 | const gate = Promise.withResolvers<boolean>(); |
| 45 | gates.set(input.attachment.id, gate); |
| 46 | if (gates.size === 3) firstThreeStarted.resolve(); |
| 47 | if (gates.size === 4) fourthStarted.resolve(); |
| 48 | try { |
| 49 | return await gate.promise; |
| 50 | } finally { |
| 51 | active -= 1; |
| 52 | } |
| 53 | }); |
| 54 | const queue = createComposerAttachmentUploadQueue({ upload, onChange: () => {} }); |
| 55 | const requests = [request("one"), request("two"), request("three"), request("four")]; |
| 56 | queue.sync(requests); |
| 57 | queue.sync(requests); |
| 58 | await firstThreeStarted.promise; |
| 59 | expect(upload).toHaveBeenCalledTimes(3); |
| 60 | gates.get("one")!.resolve(true); |
| 61 | await fourthStarted.promise; |
| 62 | for (const gate of gates.values()) gate.resolve(true); |
| 63 | await queue.settled(); |
| 64 | queue.sync(requests); |
| 65 | await queue.settled(); |
| 66 | expect(maximum).toBe(3); |
| 67 | expect(upload).toHaveBeenCalledTimes(4); |
| 68 | queue.dispose(); |
| 69 | }); |
| 70 | |
| 71 | it("cancels on disconnect and resumes from the same local draft on reconnect", async () => { |
| 72 | const started = Promise.withResolvers<void>(); |
| 73 | let states: Readonly<Record<string, ComposerAttachmentUploadState>> = {}; |
| 74 | let signal: AbortSignal | undefined; |
| 75 | const upload = vi.fn( |
| 76 | async (_request: ComposerAttachmentUploadRequest, currentSignal: AbortSignal) => { |
| 77 | signal = currentSignal; |
| 78 | started.resolve(); |
| 79 | return new Promise<boolean>((resolve) => |
| 80 | currentSignal.addEventListener("abort", () => resolve(false), { once: true }), |
| 81 | ); |
| 82 | }, |
| 83 | ); |
| 84 | const queue = createComposerAttachmentUploadQueue({ |
| 85 | upload, |
| 86 | onChange: (next) => { |
| 87 | states = next; |
| 88 | }, |
| 89 | }); |
| 90 | const local = request("offline-draft"); |
| 91 | queue.sync([local]); |
| 92 | await started.promise; |
| 93 | queue.sync([]); |
| 94 | await queue.settled(); |
| 95 | expect(signal?.aborted).toBe(true); |
| 96 | expect(states).toEqual({}); |
| 97 | upload.mockResolvedValueOnce(true); |
| 98 | queue.sync([local]); |
| 99 | await queue.settled(); |
| 100 | expect(upload.mock.calls[1]?.[0]).toBe(local); |
| 101 | expect(states[composerAttachmentUploadKey(environmentId, local.attachment.id)]).toEqual({ |
| 102 | status: "ready", |
| 103 | }); |
| 104 | expect(local.attachment).toMatchObject({ fileUri: "file:///documents/offline-draft.pdf" }); |
| 105 | queue.dispose(); |
| 106 | }); |
| 107 | |
| 108 | it("retries a failed attachment when the worker restores requests after reconnect", async () => { |
| 109 | let states: Readonly<Record<string, ComposerAttachmentUploadState>> = {}; |
| 110 | const upload = vi.fn().mockRejectedValueOnce(new Error("Disconnected")).mockResolvedValue(true); |
| 111 | const queue = createComposerAttachmentUploadQueue({ |
| 112 | upload, |
| 113 | onChange: (next) => { |
| 114 | states = next; |
| 115 | }, |
| 116 | }); |
| 117 | const local = request("failed-upload"); |
| 118 | const key = composerAttachmentUploadKey(environmentId, local.attachment.id); |
| 119 | queue.sync([local]); |
| 120 | await queue.settled(); |
| 121 | expect(states[key]?.status).toBe("failed"); |
| 122 | queue.sync([local]); |
| 123 | await queue.settled(); |
| 124 | expect(upload).toHaveBeenCalledTimes(1); |
| 125 | queue.sync([]); |
| 126 | queue.sync([local]); |
| 127 | await queue.settled(); |
| 128 | expect(upload).toHaveBeenCalledTimes(2); |
| 129 | expect(states[key]?.status).toBe("ready"); |
| 130 | queue.dispose(); |
| 131 | }); |
| 132 | |
| 133 | it("ignores a late completion after removal or environment switch", async () => { |
| 134 | const gate = Promise.withResolvers<boolean>(); |
| 135 | const started = Promise.withResolvers<void>(); |
| 136 | let states: Readonly<Record<string, ComposerAttachmentUploadState>> = {}; |
| 137 | const upload = vi.fn(async () => { |
| 138 | started.resolve(); |
| 139 | return gate.promise; |
| 140 | }); |
| 141 | const queue = createComposerAttachmentUploadQueue({ |
| 142 | upload, |
| 143 | onChange: (next) => { |
| 144 | states = next; |
| 145 | }, |
| 146 | }); |
| 147 | queue.sync([request("photo")]); |
| 148 | await started.promise; |
| 149 | upload.mockResolvedValueOnce(true); |
| 150 | const other = EnvironmentId.make("environment-2"); |
| 151 | queue.sync([request("photo", other)]); |
| 152 | gate.resolve(true); |
| 153 | await queue.settled(); |
| 154 | expect(states).toEqual({ [composerAttachmentUploadKey(other, "photo")]: { status: "ready" } }); |
| 155 | queue.sync([]); |
| 156 | expect(states).toEqual({}); |
| 157 | queue.dispose(); |
| 158 | }); |
| 159 | |
| 160 | it("restarts a re-added attachment after its aborted transfer finishes settling", async () => { |
| 161 | const firstStarted = Promise.withResolvers<void>(); |
| 162 | const firstSettled = Promise.withResolvers<boolean>(); |
| 163 | const secondStarted = Promise.withResolvers<void>(); |
| 164 | const secondSettled = Promise.withResolvers<boolean>(); |
| 165 | let states: Readonly<Record<string, ComposerAttachmentUploadState>> = {}; |
| 166 | let firstSignal: AbortSignal | undefined; |
| 167 | const upload = vi.fn(async (_request: ComposerAttachmentUploadRequest, signal: AbortSignal) => { |
| 168 | if (!firstSignal) { |
| 169 | firstSignal = signal; |
| 170 | firstStarted.resolve(); |
| 171 | return firstSettled.promise; |
| 172 | } |
| 173 | secondStarted.resolve(); |
| 174 | return secondSettled.promise; |
| 175 | }); |
| 176 | const queue = createComposerAttachmentUploadQueue({ |
| 177 | upload, |
| 178 | onChange: (next) => { |
| 179 | states = next; |
| 180 | }, |
| 181 | }); |
| 182 | const local = request("re-added"); |
| 183 | queue.sync([local]); |
| 184 | await firstStarted.promise; |
| 185 | queue.sync([]); |
| 186 | queue.sync([local]); |
| 187 | expect(firstSignal?.aborted).toBe(true); |
| 188 | expect(upload).toHaveBeenCalledOnce(); |
| 189 | firstSettled.resolve(false); |
| 190 | await secondStarted.promise; |
| 191 | expect(upload).toHaveBeenCalledTimes(2); |
| 192 | secondSettled.resolve(true); |
| 193 | await queue.settled(); |
| 194 | expect(states[composerAttachmentUploadKey(environmentId, local.attachment.id)]).toEqual({ |
| 195 | status: "ready", |
| 196 | }); |
| 197 | queue.dispose(); |
| 198 | }); |
| 199 | |
| 200 | it("keeps failures stable until retry and reports bounded progress", async () => { |
| 201 | let states: Readonly<Record<string, ComposerAttachmentUploadState>> = {}; |
| 202 | const progress: number[] = []; |
| 203 | const upload = vi.fn( |
| 204 | async ( |
| 205 | _request: ComposerAttachmentUploadRequest, |
| 206 | _signal: AbortSignal, |
| 207 | report: (value: number) => void, |
| 208 | ): Promise<boolean> => { |
| 209 | report(0.12); |
| 210 | report(0.13); |
| 211 | report(1.1); |
| 212 | throw new Error("Server unavailable"); |
| 213 | }, |
| 214 | ); |
| 215 | const queue = createComposerAttachmentUploadQueue({ |
| 216 | upload, |
| 217 | onChange: (next) => { |
| 218 | states = next; |
| 219 | const state = next[composerAttachmentUploadKey(environmentId, "file")]; |
| 220 | if (state?.status === "uploading") progress.push(state.progress); |
| 221 | }, |
| 222 | }); |
| 223 | queue.sync([request("file")]); |
| 224 | await queue.settled(); |
| 225 | queue.sync([request("file")]); |
| 226 | expect(upload).toHaveBeenCalledOnce(); |
| 227 | expect(states[composerAttachmentUploadKey(environmentId, "file")]).toEqual({ |
| 228 | status: "failed", |
| 229 | reason: "Server unavailable", |
| 230 | }); |
| 231 | expect(progress).toEqual([0, 0.1, 1]); |
| 232 | upload.mockImplementationOnce(async () => true); |
| 233 | queue.retry(environmentId, "file"); |
| 234 | await queue.settled(); |
| 235 | expect(states[composerAttachmentUploadKey(environmentId, "file")]).toEqual({ status: "ready" }); |
| 236 | queue.dispose(); |
| 237 | }); |
| 238 | |
| 239 | it("does not spin when an upload's draft was abandoned before persistence", async () => { |
| 240 | const upload = vi.fn(async () => false); |
| 241 | const queue = createComposerAttachmentUploadQueue({ upload, onChange: () => {} }); |
| 242 | queue.sync([request("discarded")]); |
| 243 | await queue.settled(); |
| 244 | expect(upload).toHaveBeenCalledOnce(); |
| 245 | queue.dispose(); |
| 246 | }); |
| 247 | }); |
| 248 | |
| 249 | describe("draft upload scope and offline submission", () => { |
| 250 | it("uploads question drafts in their environment without matching other thread prefixes", () => { |
| 251 | const requestId = ApprovalRequestId.make("request:1"); |
| 252 | for (const environment of [environmentId, EnvironmentId.make("remote:server")]) { |
| 253 | const threadId = ThreadId.make("thread:1"); |
| 254 | const prefix = questionAttachmentDraftPrefix(environment, threadId); |
| 255 | const key = questionAttachmentDraftKey(environment, threadId, requestId, "question:1"); |
| 256 | expect(composerDraftEnvironmentId(key, [])).toBe(environment); |
| 257 | expect(key.startsWith(prefix)).toBe(true); |
| 258 | for (const suffix of ["-extra", ":extra", "/extra", "%extra"]) { |
| 259 | const otherKey = questionAttachmentDraftKey( |
| 260 | environment, |
| 261 | ThreadId.make(`${threadId}${suffix}`), |
| 262 | requestId, |
| 263 | "question:1", |
| 264 | ); |
| 265 | expect(otherKey.startsWith(prefix)).toBe(false); |
| 266 | expect(composerDraftEnvironmentId(otherKey, [])).toBe(environment); |
| 267 | } |
| 268 | } |
| 269 | }); |
| 270 | it("resolves thread, new-task, and queued-task drafts without crossing environments", () => { |
| 271 | expect(composerDraftEnvironmentId("environment-1:thread", [])).toBe(environmentId); |
| 272 | expect(composerDraftEnvironmentId("new-task:environment-1:project", [])).toBe(environmentId); |
| 273 | expect( |
| 274 | composerDraftEnvironmentId("pending-task:message", [{ messageId: "message", environmentId }]), |
| 275 | ).toBe(environmentId); |
| 276 | expect(composerDraftEnvironmentId("pending-task:missing", [])).toBeNull(); |
| 277 | const colonEnvironment = EnvironmentId.make("a:vcs-status:b"); |
| 278 | expect(composerDraftEnvironmentId(`${colonEnvironment}:thread`, [])).toBe(colonEnvironment); |
| 279 | expect(composerDraftEnvironmentId(`new-task:${colonEnvironment}:project`, [])).toBe( |
| 280 | colonEnvironment, |
| 281 | ); |
| 282 | }); |
| 283 | |
| 284 | it("reads an id-keyed new-task draft's environment from its project stamp", () => { |
| 285 | const stamped = { |
| 286 | project: { |
| 287 | environmentId, |
| 288 | projectId: "project" as never, |
| 289 | createdAt: "2026-09-05T00:00:00.000Z", |
| 290 | }, |
| 291 | }; |
| 292 | expect(composerDraftEnvironmentId("new-task:abc123-def456", [], stamped)).toBe(environmentId); |
| 293 | // An id-keyed draft that lost its stamp belongs to nobody: uploads must |
| 294 | // not start and sign-out must not sweep it into some other environment. |
| 295 | expect(composerDraftEnvironmentId("new-task:abc123-def456", [])).toBeNull(); |
| 296 | // The stamp wins over a legacy-looking key when both are present. |
| 297 | expect(composerDraftEnvironmentId("new-task:environment-2:project", [], stamped)).toBe( |
| 298 | environmentId, |
| 299 | ); |
| 300 | }); |
| 301 | |
| 302 | it("only a failed upload blocks sending; an in-flight one queues instead", () => { |
| 303 | const key = composerAttachmentUploadKey(environmentId, "file"); |
| 304 | const input = { |
| 305 | environmentId, |
| 306 | attachments: [request("file").attachment], |
| 307 | connected: true, |
| 308 | serverConfig: { |
| 309 | environment: { |
| 310 | capabilities: { attachmentUploads: true, fileAttachments: { maxUploadBytes: 1024 } }, |
| 311 | }, |
| 312 | }, |
| 313 | states: {}, |
| 314 | }; |
| 315 | // Not started yet and mid-transfer both let the send through as a queued |
| 316 | // message; the outbox drain finishes (or redoes) the upload. |
| 317 | expect(composerAttachmentUploadBlockReason(input)).toBeNull(); |
| 318 | expect(composerAttachmentsStillUploading(input)).toBe(true); |
| 319 | expect( |
| 320 | composerAttachmentsStillUploading({ |
| 321 | ...input, |
| 322 | states: { [key]: { status: "uploading", progress: 0.6 } }, |
| 323 | }), |
| 324 | ).toBe(true); |
| 325 | expect(composerAttachmentUploadBlockReason({ ...input, connected: false })).toBeNull(); |
| 326 | expect( |
| 327 | composerAttachmentUploadBlockReason({ |
| 328 | ...input, |
| 329 | states: { [key]: { status: "failed", reason: "Offline" } }, |
| 330 | }), |
| 331 | ).toBe("Retry or remove the failed attachment"); |
| 332 | expect( |
| 333 | composerAttachmentsStillUploading({ |
| 334 | ...input, |
| 335 | states: { [key]: { status: "failed", reason: "Offline" } }, |
| 336 | }), |
| 337 | ).toBe(false); |
| 338 | expect( |
| 339 | composerAttachmentUploadBlockReason({ ...input, states: { [key]: { status: "ready" } } }), |
| 340 | ).toBeNull(); |
| 341 | expect( |
| 342 | composerAttachmentsStillUploading({ ...input, states: { [key]: { status: "ready" } } }), |
| 343 | ).toBe(false); |
| 344 | // Attachments the environment cannot accept never count as uploading. |
| 345 | expect(composerAttachmentsStillUploading({ ...input, serverConfig: null })).toBe(false); |
| 346 | }); |
| 347 | }); |