Ein zugewiesener Lauf fiel bisher nur auf, wenn jemand die Uebersicht oeffnete; ein ueberfaelliger ueberhaupt nicht. Vier Anlaesse gehen jetzt hinaus — zugewiesen, ueberfaellig, abgeschlossen und Verzeichnisabgleich fehlgeschlagen —, je Person abschaltbar. Niemand bekommt Post ueber die eigene Tat: das ist der haeufigste Grund, Benachrichtigungen ganz abzuschalten, und deshalb faellt der Handelnde immer aus der Empfaengerliste. Eingereiht wird im Anfrage-Pfad, gesendet wird es nicht. Ein haengender Mailserver darf kein Abhaken blockieren, also schreibt der Dienst eine Zeile in `Zustellung` und ein eigener Zeitplan schickt sie. Betreff und Rumpf stehen fertig darin: eine Meldung sagt, was *damals* war. In keiner Nachricht steht ein Schrittinhalt, und damit auch kein erzeugtes Passwort. Darueber wacht src/lib/meldungen.ts — die Funktionen dort nehmen nur Kopfdaten, der Typ laesst nichts anderes zu. Geprueft wird es zusaetzlich ueber alle Rumpftexte, die wirklich verschickt wurden, so wie #012 es fuer die Druckansicht tat. Zwei Dinge haetten ohne Datenbank nicht gehalten. Die Einmaligkeit der Ueberfaelligkeitsmeldung liegt in einem partiellen eindeutigen Index: der Zeitplan sieht jede Minute nach, und ohne ihn bekaeme der Zustaendige fuer einen Lauf, der ueber das Wochenende steht, 2880 Mails — jede fuer sich korrekt, kein Test rot. Und die Sperre der Warteschlange liegt in derselben Tabelle wie die des Abgleichs, denn hinter einem Lastverteiler laufen zwei Instanzen. Eine stille Warteschlange ist schlimmer als keine: Verwaltung → Zustellungen zeigt mit Grund, was hinausging und was nicht. Geprueft gegen einen echten SMTP-Faenger (mailpit) statt gegen eine abgefangene Funktion — und `mailpit` gehoert damit ins Tor vor dem Commit, sonst ueberspringt die Suite 3 Tests und sieht gruen aus. Closes #54 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
144 lines
5.5 KiB
TypeScript
144 lines
5.5 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
||
|
||
/**
|
||
* Der Zeitplan des Verzeichnis-Abgleichs (#016).
|
||
*
|
||
* Geprueft wird der **Durchgang**, nicht die Uhr: dass `node-cron` jede Minute
|
||
* ausloest, ist Sache der Bibliothek; dass ein Durchgang die faelligen Quellen
|
||
* holt, jede unter der Sperre abgleicht und an einem Fehler nicht abbricht, ist
|
||
* unsere.
|
||
*
|
||
* Die Dienste darunter werden ersetzt. Das ist hier richtig und nicht faul: was
|
||
* der Abgleich **tut**, prueft `tests/abgleich.test.ts` gegen einen echten
|
||
* LDAP-Container. Hier geht es allein um die Ablaufsteuerung — und die laesst
|
||
* sich mit echten Diensten nur pruefen, indem man eine Minute wartet und ein
|
||
* Verzeichnis danebenstellt.
|
||
*/
|
||
|
||
const abgleichAusfuehren = vi.fn();
|
||
const faelligeQuellen = vi.fn();
|
||
/** Nur da, um zu belegen, dass der Zeitplan sie **nicht** nimmt. */
|
||
const mitSperre = vi.fn();
|
||
|
||
/** Dieselbe Klasse wie im Dienst – `instanceof` entscheidet ueber das Verhalten. */
|
||
class AbgleichLaeuftBereitsFehler extends Error {}
|
||
|
||
vi.mock("@/server/services/abgleich", () => ({
|
||
AbgleichLaeuftBereitsFehler,
|
||
abgleichAusfuehren: (...args: unknown[]) => abgleichAusfuehren(...args),
|
||
faelligeQuellen: (...args: unknown[]) => faelligeQuellen(...args),
|
||
}));
|
||
|
||
// Seit #034 liegt die Sperre in `services/sperre`. Gemockt wird sie weiter, um
|
||
// zu belegen, dass der Zeitplan sie **nicht** selbst nimmt.
|
||
vi.mock("@/server/services/sperre", () => ({
|
||
mitSperre: (...args: unknown[]) => mitSperre(...args),
|
||
}));
|
||
|
||
const { abgleichDurchgang, zeitplanAnhalten, zeitplanLaeuft, zeitplanStarten } =
|
||
await import("@/server/zeitplan/abgleich-plan");
|
||
|
||
afterEach(() => {
|
||
zeitplanAnhalten();
|
||
vi.clearAllMocks();
|
||
vi.restoreAllMocks();
|
||
});
|
||
|
||
describe("Ein Durchgang des Zeitplans", () => {
|
||
it("gleicht jede fällige Quelle ab", async () => {
|
||
faelligeQuellen.mockResolvedValue([
|
||
{ id: "q1", name: "Firma AD" },
|
||
{ id: "q2", name: "Zweigstelle" },
|
||
]);
|
||
abgleichAusfuehren.mockResolvedValue({ status: "ERFOLG" });
|
||
|
||
await abgleichDurchgang();
|
||
|
||
expect(abgleichAusfuehren).toHaveBeenCalledTimes(2);
|
||
expect(abgleichAusfuehren.mock.calls.map((aufruf) => aufruf[0])).toEqual(["q1", "q2"]);
|
||
/*
|
||
* **Ohne eigene Sperre.** Der Dienst nimmt sie selbst, in der Datenbank und
|
||
* damit ueber Instanzen hinweg. Eine zweite hier traege einen anderen
|
||
* Schluessel, liefe an der ersten vorbei — und ein von Hand angestossener
|
||
* Abgleich saehe fuer den Zeitplan wie ein unerwarteter Fehler aus.
|
||
*/
|
||
expect(mitSperre).not.toHaveBeenCalled();
|
||
/*
|
||
* Der handelnde „Benutzer“ ist der Systemlauf: ADMIN, damit die Rechtepruefung
|
||
* des Dienstes greift, und ohne Id, weil kein Mensch es war. Der Dienst macht
|
||
* daraus im Protokoll `benutzerId: null`.
|
||
*/
|
||
const zweitesArgument = abgleichAusfuehren.mock.calls[0]?.[1];
|
||
expect(zweitesArgument).toEqual({ id: "", rolle: "ADMIN" });
|
||
});
|
||
|
||
it("überspringt still, was gerade schon abgeglichen wird", async () => {
|
||
/*
|
||
* Die belegte Sperre meldet der Dienst als eigene Ausnahme. Im Normalbetrieb
|
||
* mit zwei Instanzen — oder waehrend jemand „Jetzt abgleichen“ drueckt —
|
||
* waere das jede Minute eine Zeile im Protokoll, deshalb weder Fehler noch
|
||
* Ausgabe.
|
||
*/
|
||
faelligeQuellen.mockResolvedValue([
|
||
{ id: "q1", name: "Firma AD" },
|
||
{ id: "q2", name: "Zweigstelle" },
|
||
]);
|
||
abgleichAusfuehren.mockImplementation(async (id: string) => {
|
||
if (id === "q1") throw new AbgleichLaeuftBereitsFehler("läuft schon");
|
||
return { status: "ERFOLG" };
|
||
});
|
||
const fehlerAusgabe = vi.spyOn(console, "error").mockImplementation(() => {});
|
||
|
||
await abgleichDurchgang();
|
||
|
||
// Und die naechste Quelle kommt trotzdem dran.
|
||
expect(abgleichAusfuehren).toHaveBeenCalledTimes(2);
|
||
expect(fehlerAusgabe).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("macht nach einem unerwarteten Fehler mit der nächsten Quelle weiter", async () => {
|
||
/*
|
||
* Der Fall, der den Zeitplan sonst still stillegt: eine Quelle wirft etwas,
|
||
* das der Dienst nicht abgefangen hat. Ohne diese Schleife stuende danach
|
||
* der Abgleich **aller** Quellen — bis jemand den Prozess neu startet.
|
||
*/
|
||
faelligeQuellen.mockResolvedValue([
|
||
{ id: "kaputt", name: "Antwortet nicht" },
|
||
{ id: "q2", name: "Zweigstelle" },
|
||
]);
|
||
abgleichAusfuehren.mockImplementation(async (id: string) => {
|
||
if (id === "kaputt") throw new Error("Netz weg");
|
||
return { status: "ERFOLG" };
|
||
});
|
||
const fehlerAusgabe = vi.spyOn(console, "error").mockImplementation(() => {});
|
||
|
||
await abgleichDurchgang();
|
||
|
||
expect(abgleichAusfuehren).toHaveBeenCalledTimes(2);
|
||
expect(abgleichAusfuehren.mock.calls[1]?.[0]).toBe("q2");
|
||
expect(fehlerAusgabe).toHaveBeenCalledOnce();
|
||
// Der Name der Quelle gehört in die Meldung, sonst sucht jemand im Dunkeln.
|
||
expect(String(fehlerAusgabe.mock.calls[0]?.[0])).toContain("Antwortet nicht");
|
||
});
|
||
|
||
it("tut nichts, wenn keine Quelle fällig ist", async () => {
|
||
faelligeQuellen.mockResolvedValue([]);
|
||
await abgleichDurchgang();
|
||
expect(abgleichAusfuehren).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
describe("Der Zeitplan selbst", () => {
|
||
it("startet genau einmal", () => {
|
||
// Next laedt Module im Entwicklungsmodus mehrfach; ein zweiter Zeitplan
|
||
// liefe unbemerkt mit und verdoppelte jeden Durchgang.
|
||
expect(zeitplanLaeuft()).toBe(false);
|
||
zeitplanStarten();
|
||
zeitplanStarten();
|
||
expect(zeitplanLaeuft()).toBe(true);
|
||
|
||
zeitplanAnhalten();
|
||
expect(zeitplanLaeuft()).toBe(false);
|
||
});
|
||
});
|