feat: Sprachnotizen in Synchronisierung, Warteschlange und Abmelden (T7 L5 Task 18)
Backend Image / build-and-push (push) Successful in 7m6s
Frontend Image / build-and-push (push) Successful in 15m27s
Manifests / validate (push) Successful in 16s

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]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-16 11:10:14 +02:00
1 parent 0c7147f626
commit e614ded967
18 files changed
+1201 -30

No files matched your search

@@ -0,0 +1,192 @@
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);
});
});