Wartende Sprachnotizen werden nach Einträgen und Fotos hochgeladen, je Notiz ein fester Idempotency-Key; endgültige 4xx markieren die Notiz mit festem deutschem Text, 401/408/429/5xx/Netz warten auf den nächsten Lauf, „wird bearbeitet“ wird übersprungen. Warteschlangen-Sheet mit Abschnitt „Sprachnotizen“ (Verwerfen je Notiz nur für den Besitzer, alle verwerfen mit Rückfrage, Hinweis bei zu vielen offenen Notizen), Offline-Band zählt nur wartende Notizen, Abmelden fragt bei wartenden Notizen nach. Zählen liest keine Audiodaten. Gates: Frontend lint, typecheck, 2886 Tests. Refs #27 Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
193 lines
7.3 KiB
TypeScript
193 lines
7.3 KiB
TypeScript
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<typeof import("./api").uploadVoiceCapture>;
|
|
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);
|
|
});
|
|
});
|