feat(verzeichnis): LDAP-Abgleich, Zeitplan, CSV-Import und Log
Prüfung / pruefung (push) Canceled after 0s
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
Prüfung / e2e (pull_request) Canceled after 0s

Quelle anlegen, Verbindung testen, nach Zeitplan oder auf Knopfdruck
abgleichen. Gelesen wird seitenweise mit searchPaginated, objectGUID
kommt als Puffer und wird gemischt-endian zur GUID umsortiert. Wer im
Verzeichnis verschwindet, wird hier inaktiv und nie geloescht.

Drei Regeln verhindern, dass eine kaputte Eingabe wie "alle sind
gegangen" aussieht: laesst sich kein einziger Eintrag zuordnen, wird
niemand deaktiviert; uebersprungene Eintraege werden am dn erkannt und
ausgenommen; eine von Hand angelegte Person wird uebernommen statt
verdoppelt.

Der Zeitplan laeuft ueber node-cron im selben Prozess, gestartet aus
src/instrumentation.ts, und nimmt keine eigene Sperre - die liegt in der
Datenbank, damit zwei Instanzen sich einig sind.

Closes #16

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-26 02:21:08 +02:00
co-authored by Claude Opus 5
parent ab5777a57e
commit 16059e7846
57 changed files with 6834 additions and 61 deletions
+138
View File
@@ -0,0 +1,138 @@
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 mitAbgleichSperre = 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),
mitAbgleichSperre: (...args: unknown[]) => mitAbgleichSperre(...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(mitAbgleichSperre).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);
});
});