import "fake-indexeddb/auto"; import { IDBFactory } from "fake-indexeddb"; import { beforeEach, describe, expect, it, vi, type MockedFunction } from "vitest"; vi.mock("./api", () => ({ uploadVoiceCapture: vi.fn() })); type Upload = MockedFunction; let pending: typeof import("./pending-voice"); let upload: typeof import("./upload-pending-voice"); let uploadVoiceCapture: Upload; let ApiError: typeof import("@/lib/auth/api-client").ApiError; const input = (ownerUserId = "user-1", colonyId = "colony-1") => ({ ownerUserId, blob: new Blob(["audio"], { type: "audio/webm;codecs=opus" }), mime: "audio/webm;codecs=opus", durationSec: 42, colonyId, inspectionDate: "2026-10-16", }); const never = () => false; describe("uploadPendingVoice", () => { beforeEach(async () => { globalThis.indexedDB = new IDBFactory(); vi.stubGlobal("navigator", { storage: { persist: vi.fn().mockResolvedValue(true) } }); vi.resetModules(); pending = await import("./pending-voice"); upload = await import("./upload-pending-voice"); uploadVoiceCapture = vi.mocked((await import("./api")).uploadVoiceCapture); ApiError = (await import("@/lib/auth/api-client")).ApiError; uploadVoiceCapture.mockReset(); uploadVoiceCapture.mockResolvedValue({ id: "cap-1", status: "QUEUED" }); }); it("sends a recorded note with its stable key", async () => { const note = await pending.queueVoiceNote(input()); const result = await upload.uploadPendingVoice("user-1", never); expect(result).toEqual({ synced: 1, failed: 0, skippedForeign: 0 }); expect(uploadVoiceCapture).toHaveBeenCalledTimes(1); expect(uploadVoiceCapture.mock.calls[0][0].uploadIdempotencyKey).toBe( note.uploadIdempotencyKey, ); }); it("deletes the local audio after a 2xx", async () => { await pending.queueVoiceNote(input()); await upload.uploadPendingVoice("user-1", never); expect(await pending.listPendingVoice("user-1")).toEqual([]); }); it("sends the same key again after a lost answer", async () => { const note = await pending.queueVoiceNote(input()); uploadVoiceCapture.mockRejectedValueOnce(new TypeError("Failed to fetch")); await upload.uploadPendingVoice("user-1", never); await upload.uploadPendingVoice("user-1", never); const keys = uploadVoiceCapture.mock.calls.map(([n]) => n.uploadIdempotencyKey); expect(keys).toEqual([note.uploadIdempotencyKey, note.uploadIdempotencyKey]); expect(await pending.listPendingVoice("user-1")).toEqual([]); }); it("keeps 4xx rejections as lastError and does not send them again", async () => { await pending.queueVoiceNote(input()); uploadVoiceCapture.mockRejectedValueOnce( new ApiError(415, "Dieses Audioformat wird nicht unterstützt.", { code: "UNSUPPORTED_AUDIO", }), ); const first = await upload.uploadPendingVoice("user-1", never); const second = await upload.uploadPendingVoice("user-1", never); const [note] = await pending.listPendingVoice("user-1"); expect(note.lastError).toMatchObject({ status: 415, details: { code: "UNSUPPORTED_AUDIO" } }); expect(first.failed).toBe(1); expect(second).toEqual({ synced: 0, failed: 0, skippedForeign: 0 }); expect(uploadVoiceCapture).toHaveBeenCalledTimes(1); }); it.each([429, 503, 408])("treats %i as try later and keeps the rest", async (status) => { await pending.queueVoiceNote(input()); await pending.queueVoiceNote(input("user-1", "colony-2")); uploadVoiceCapture.mockRejectedValueOnce(new ApiError(status, "später")); const result = await upload.uploadPendingVoice("user-1", never); expect(result.synced).toBe(0); const notes = await pending.listPendingVoice("user-1"); expect(notes).toHaveLength(2); expect(notes.every((n) => n.lastError === undefined)).toBe(true); }); it("a 409 'still being processed' skips that note and goes on", async () => { await pending.queueVoiceNote(input()); await pending.queueVoiceNote(input("user-1", "colony-2")); uploadVoiceCapture.mockRejectedValueOnce( new ApiError(409, "Diese Anfrage wird bereits verarbeitet - bitte kurz warten"), ); const result = await upload.uploadPendingVoice("user-1", never); expect(uploadVoiceCapture).toHaveBeenCalledTimes(2); expect(result).toEqual({ synced: 1, failed: 0, skippedForeign: 0 }); const [left] = await pending.listPendingVoice("user-1"); expect(left.lastError).toBeUndefined(); }); it("any other 409 is a real refusal and is kept on the note", async () => { await pending.queueVoiceNote(input()); uploadVoiceCapture.mockRejectedValueOnce(new ApiError(409, "Schlüssel schon benutzt")); await upload.uploadPendingVoice("user-1", never); const [note] = await pending.listPendingVoice("user-1"); expect(note.lastError).toMatchObject({ status: 409 }); }); it("remembers that a run stopped on TOO_MANY_OPEN", async () => { await pending.queueVoiceNote(input()); uploadVoiceCapture.mockRejectedValueOnce( new ApiError(429, "x", { code: "TOO_MANY_OPEN" }), ); await upload.uploadPendingVoice("user-1", never); const [note] = await pending.listPendingVoice("user-1"); expect(note.blockedBy).toBe("TOO_MANY_OPEN"); expect(note.lastError).toBeUndefined(); }); it("stops on a network error and keeps the rest", async () => { await pending.queueVoiceNote(input()); await pending.queueVoiceNote(input("user-1", "colony-2")); uploadVoiceCapture.mockRejectedValue(new TypeError("Failed to fetch")); const result = await upload.uploadPendingVoice("user-1", never); expect(uploadVoiceCapture).toHaveBeenCalledTimes(1); expect(result.failed).toBe(1); expect(await pending.listPendingVoice("user-1")).toHaveLength(2); }); it("stops on 401 without marking the note", async () => { await pending.queueVoiceNote(input()); uploadVoiceCapture.mockRejectedValue(new ApiError(401, "Nicht angemeldet")); await upload.uploadPendingVoice("user-1", never); const [note] = await pending.listPendingVoice("user-1"); expect(note.lastError).toBeUndefined(); }); it("never sends another user's recordings", async () => { await pending.queueVoiceNote(input("user-2")); const result = await upload.uploadPendingVoice("user-1", never); expect(uploadVoiceCapture).not.toHaveBeenCalled(); expect(result.skippedForeign).toBe(1); expect(await pending.listPendingVoice("user-2")).toHaveLength(1); }); it("a cancelled run sends no voice note", async () => { await pending.queueVoiceNote(input()); await upload.uploadPendingVoice("user-1", () => true); expect(uploadVoiceCapture).not.toHaveBeenCalled(); expect(await pending.listPendingVoice("user-1")).toHaveLength(1); }); it("skips a note discarded while the run was going", async () => { const first = await pending.queueVoiceNote(input()); await pending.queueVoiceNote(input("user-1", "colony-2")); uploadVoiceCapture.mockImplementationOnce(async () => { const [, second] = await pending.listPendingVoice("user-1"); await pending.discardPendingVoice(second.id, "user-1"); return { id: first.id, status: "QUEUED" as const }; }); const result = await upload.uploadPendingVoice("user-1", never); expect(uploadVoiceCapture).toHaveBeenCalledTimes(1); expect(result.synced).toBe(1); }); });