/** * @vitest-environment jsdom * * Die Warteschlange lebt im Browser: sie fragt `window` und `indexedDB`. Der * Rest der Testsuite läuft in Node, deshalb hier ausdrücklich jsdom — und * darin fake-indexeddb, denn eine IndexedDB bringt jsdom nicht mit. */ // IndexedDB gibt es in Node nicht — fake-indexeddb legt eine echte // Implementierung ins globale Objekt. Der Import muss vor allem stehen, was // Dexie anfasst, deshalb ganz oben. import "fake-indexeddb/auto"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { anzahlOffen, datenbank, einreihen, synchronisieren, verwerfen, warteschlange, type SendeVorgang, } from "@/lib/kiosk/outbox"; import type { VorgangErgebnis } from "@/lib/kiosk/vorgaenge"; /** * Die Warteschlange des Terminals. * * Hier hängt daran, ob im Gerätehaus etwas verloren geht: Meldungen dürfen * erst verschwinden, wenn der Server sie hat, und ein zweiter Sendeversuch * darf nichts doppeln. Beides lässt sich ohne Browser prüfen — die Logik * steckt in dieser Datei, nicht in der Oberfläche. */ /** Antwortet auf alles mit „angenommen". */ function serverNimmtAlles(): { sender: ReturnType; gesehen: SendeVorgang[][] } { const gesehen: SendeVorgang[][] = []; const sender = vi.fn(async (vorgaenge: SendeVorgang[]) => { gesehen.push(vorgaenge); return { ergebnisse: vorgaenge.map((vorgang) => ({ id: vorgang.id, erfolgreich: true, bereitsVerarbeitet: false, })), }; }); return { sender, gesehen }; } beforeEach(async () => { await datenbank().vorgaenge.clear(); }); describe("Warteschlange des Terminals", () => { it("nimmt einen Vorgang an und gibt ihm eine eigene Kennung", async () => { const eintrag = await einreihen( { art: "ANWESENHEIT_KOMMEN", daten: { anlass: "UEBUNG" } }, "Gekommen (Übung)", ); expect(eintrag.id).toMatch(/^[0-9a-f-]{36}$/); expect(await anzahlOffen()).toBe(1); const liste = await warteschlange(); expect(liste[0]?.beschriftung).toBe("Gekommen (Übung)"); expect(liste[0]?.versuche).toBe(0); }); it("arbeitet die Vorgänge in der Reihenfolge der Erfassung ab", async () => { await einreihen({ art: "ANWESENHEIT_KOMMEN", daten: { anlass: "DIENST" } }, "erster"); // Ohne Abstand hätten beide dieselbe ISO-Zeit — im Gerätehaus liegen // zwischen zwei Vorgängen Sekunden, hier reicht eine Millisekunde. await new Promise((fertig) => setTimeout(fertig, 5)); await einreihen({ art: "ANWESENHEIT_GEHEN", daten: { anwesenheitId: null } }, "zweiter"); const liste = await warteschlange(); expect(liste.map((eintrag) => eintrag.beschriftung)).toEqual(["erster", "zweiter"]); }); it("löscht erst, wenn der Server bestätigt hat", async () => { await einreihen( { art: "MANGEL_MELDEN", daten: { inventarnummer: "INV-0001", beschreibung: "Riss", prioritaet: "MITTEL" }, }, "Mangel an INV-0001", ); const { sender } = serverNimmtAlles(); const ergebnis = await synchronisieren(sender); expect(ergebnis.gesendet).toBe(1); expect(ergebnis.erledigt).toBe(1); expect(await anzahlOffen()).toBe(0); }); it("behält alles, wenn kein Netz da ist, und zählt den Versuch", async () => { await einreihen({ art: "ANWESENHEIT_KOMMEN", daten: { anlass: "EINSATZ" } }, "Gekommen"); const ergebnis = await synchronisieren(async () => { throw new TypeError("Failed to fetch"); }); expect(ergebnis.erledigt).toBe(0); expect(ergebnis.verblieben).toBe(1); expect(ergebnis.fehler).toContain("Failed to fetch"); const liste = await warteschlange(); expect(liste[0]?.versuche).toBe(1); }); it("schickt denselben Vorgang mit derselben Kennung erneut", async () => { const eintrag = await einreihen( { art: "WAESCHE_ABGEBEN", daten: { bezeichnung: "Einsatzjacke", menge: 1 } }, "Wäsche abgegeben", ); // Erster Versuch: die Antwort geht verloren. await synchronisieren(async () => { throw new Error("Verbindung abgebrochen"); }); expect(await anzahlOffen()).toBe(1); // Zweiter Versuch: derselbe Vorgang, dieselbe Kennung — daran erkennt ihn // der Server wieder. const { sender, gesehen } = serverNimmtAlles(); await synchronisieren(sender); expect(gesehen[0]?.[0]?.id).toBe(eintrag.id); expect(await anzahlOffen()).toBe(0); }); it("nimmt „bereits verarbeitet“ als Erfolg — sonst bliebe der Vorgang ewig liegen", async () => { const eintrag = await einreihen( { art: "ANWESENHEIT_GEHEN", daten: { anwesenheitId: null } }, "Gegangen", ); const ergebnis = await synchronisieren(async () => ({ ergebnisse: [{ id: eintrag.id, erfolgreich: false, bereitsVerarbeitet: true }], })); expect(ergebnis.erledigt).toBe(1); expect(await anzahlOffen()).toBe(0); }); it("nimmt einen Konflikt aus der Warteschlange und zählt ihn getrennt", async () => { const eintrag = await einreihen( { art: "MANGEL_MELDEN", daten: { inventarnummer: "INV-9999", beschreibung: "kaputt", prioritaet: "HOCH" }, }, "Mangel an INV-9999", ); const ergebnis = await synchronisieren(async () => ({ ergebnisse: [ { id: eintrag.id, erfolgreich: false, bereitsVerarbeitet: false, konflikt: true, meldung: "Gerät gibt es nicht", }, ], })); // Der Vorgang liegt jetzt beim Server in der Konfliktliste — hier ist er // fertig, sonst käme er bei jedem Sendeversuch wieder. expect(ergebnis.konflikte).toBe(1); expect(ergebnis.erledigt).toBe(1); expect(await anzahlOffen()).toBe(0); }); it("merkt sich den Grund, wenn der Server einen Vorgang ablehnt", async () => { const eintrag = await einreihen( { art: "FAHRT_BEENDEN", daten: { fahrtId: eintragKennung(), kilometerRueckkehr: 100 } }, "Fahrt beendet", ); await synchronisieren(async () => ({ ergebnisse: [ { id: eintrag.id, erfolgreich: false, bereitsVerarbeitet: false, meldung: "Kilometerstand kleiner als bei der Abfahrt", }, ], })); const liste = await warteschlange(); expect(liste).toHaveLength(1); expect(liste[0]?.versuche).toBe(1); expect(liste[0]?.letzterFehler).toContain("Kilometerstand"); }); it("lädt ein wartendes Foto zuerst hoch und hängt die Kennung an die Meldung", async () => { const bild = new Blob(["nicht wirklich ein Bild"], { type: "image/jpeg" }); await einreihen( { art: "MANGEL_MELDEN", daten: { inventarnummer: "INV-0002", beschreibung: "Delle", prioritaet: "MITTEL" }, }, "Mangel an INV-0002", { inhalt: bild, name: "mangel.jpg" }, ); const fotoSender = vi.fn(async () => "3f2504e0-4f89-41d3-9a0c-0305e82c3301"); const { sender, gesehen } = serverNimmtAlles(); await synchronisieren(sender, fotoSender); expect(fotoSender).toHaveBeenCalledOnce(); expect(gesehen[0]?.[0]?.daten).toMatchObject({ fotoDateiId: "3f2504e0-4f89-41d3-9a0c-0305e82c3301", }); }); it("schickt die Meldung auch dann, wenn das Foto nicht durchgeht", async () => { const bild = new Blob(["x"], { type: "image/jpeg" }); await einreihen( { art: "MANGEL_MELDEN", daten: { inventarnummer: "INV-0003", beschreibung: "Riss", prioritaet: "KRITISCH" }, }, "Mangel an INV-0003", { inhalt: bild, name: "mangel.jpg" }, ); const { sender } = serverNimmtAlles(); const ergebnis = await synchronisieren(sender, async () => { throw new Error("Speicher nicht erreichbar"); }); // Eine Meldung ohne Bild ist besser als gar keine. expect(ergebnis.erledigt).toBe(1); expect(await anzahlOffen()).toBe(0); }); it("überträgt höchstens so viele Vorgänge, wie erlaubt sind", async () => { for (let zaehler = 0; zaehler < 5; zaehler += 1) { await einreihen({ art: "ANWESENHEIT_KOMMEN", daten: { anlass: "DIENST" } }, `Nr ${zaehler}`); await new Promise((fertig) => setTimeout(fertig, 2)); } const { sender } = serverNimmtAlles(); const ergebnis = await synchronisieren(sender, undefined, 2); expect(ergebnis.gesendet).toBe(2); expect(ergebnis.verblieben).toBe(3); }); it("lässt einen Vorgang von Hand verwerfen", async () => { const eintrag = await einreihen( { art: "ANWESENHEIT_KOMMEN", daten: { anlass: "SONSTIGES" } }, "Gekommen", ); await verwerfen(eintrag.id); expect(await anzahlOffen()).toBe(0); }); }); /** Eine beliebige, aber gültige UUID für Nutzlasten, die eine verlangen. */ function eintragKennung(): string { return "6b1d2f3a-4c5e-4a7b-8c9d-0e1f2a3b4c5d"; }