/voice/new: großer Aufnahmeknopf mit Zeit, Hinweis bei 150 s, Stopp bei 180 s; eine automatisch beendete Aufnahme liegt sofort in der Warteschlange des Geräts (Ruling 13); Abbrechen und Neu aufnehmen fragen nach; Hilfe bei gesperrtem Mikrofon; Fokus bleibt bei jedem Zustandswechsel an einem Bedienelement; Volk und Datum während der Aufnahme gesperrt, ?date= auf die letzten 7 Tage begrenzt. /voice: wartende Notizen des Geräts zuerst, dann die des Servers mit Status, „Prüfen“ nur bei fertigen, Verwerfen mit Rückfrage. Einstiege über Schnellanlage, Menü (nur mit voice-captures:use und inspections:create), Volksseite und Durchsicht. Bei ausgeschalteter Aufnahme verschwinden die Einstiege; jeder HTTP-Fehler der Konfiguration zählt als aus, nur fehlendes Netz als unbekannt. Fehlertexte gleich mit dem Backend (Test). Gates: Frontend lint, typecheck, 2979 Tests, build. Refs #27 Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
207 lines
7.8 KiB
TypeScript
207 lines
7.8 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("keeps a 403 VOICE_RECORDING_DISABLED as a definitive refusal on the note", async () => {
|
|
const note = await pending.queueVoiceNote(input());
|
|
uploadVoiceCapture.mockRejectedValueOnce(
|
|
new ApiError(403, "off", { code: "VOICE_RECORDING_DISABLED" }),
|
|
);
|
|
|
|
const result = await upload.uploadPendingVoice("user-1", never);
|
|
|
|
expect(result.failed).toBe(1);
|
|
const [kept] = await pending.listPendingVoice("user-1");
|
|
expect(kept.id).toBe(note.id);
|
|
expect(kept.lastError?.status).toBe(403);
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|