import { describe, expect, it } from "vitest"; import type { ImportSheet } from "@/lib/import/parse"; import { LEERER_BESTAND, pruefe, type Bestand } from "@/lib/import/validate"; // Die Prüfung entscheidet, ob 800 fremde Zeilen in eine Personaldatenbank // dürfen. Getestet wird deshalb nicht, dass gute Dateien durchgehen — das // wäre die leichte Hälfte —, sondern dass jede einzelne Regel eine schlechte // Datei aufhält, und zwar mit Zeilennummer. function blatt(name: string, spalten: string[], zeilen: string[][]): ImportSheet { return { name, spalten, zeilen: zeilen.map((werte, i) => ({ zeile: i + 2, werte: Object.fromEntries(spalten.map((s, j) => [s, werte[j] ?? ""])), })), }; } const ORG_SPALTEN = ["Orgnummer", "Bezeichnung", "Art", "Übergeordnet"]; const PERSON_SPALTEN = [ "Personalnummer", "Vorname", "Nachname", "Geschlecht", "Geburtsdatum", "E-Mail", "Tätigkeit", "Standort", "Planstellennummer", "Eintritt", ]; /** Ein Bestand, in dem Standort und Planstelle bereits existieren. */ const BESTAND: Bestand = { ...LEERER_BESTAND, standorte: new Map([["Wien", "l1"]]), planstellen: new Map([ ["60000001", { id: "p1", besetzt: false }], ["60000002", { id: "p2", besetzt: false }], ["60000009", { id: "p9", besetzt: true }], ]), }; const person = (pnr: string, mail: string, stelle: string, extra: Partial> = {}) => [ pnr, "Sabine", "Aigner", "w", "15.08.1968", mail, "Projektingenieur:in", extra.Standort ?? "Wien", stelle, extra.Eintritt ?? "01.03.2015", ]; function meldungen(f: { blatt: string; zeile: number | null; spalte: string | null; meldung: string }[]) { return f.map((x) => `${x.blatt}/${x.zeile ?? "-"}/${x.spalte ?? "-"}: ${x.meldung}`); } describe("Aufbau der Organisation", () => { it("besteht auf genau einer Wurzel", () => { const zwei = pruefe( [blatt("Organisation", ORG_SPALTEN, [["1", "A", "Gesellschaft", ""], ["2", "B", "Gesellschaft", ""]])], LEERER_BESTAND ); expect(meldungen(zwei.fehler).join(" ")).toContain("2 Zeilen „Gesellschaft“"); const keine = pruefe([blatt("Organisation", ORG_SPALTEN, [["2", "B", "Bereich", "1"]])], LEERER_BESTAND); expect(meldungen(keine.fehler).join(" ")).toContain("keine Wurzel"); }); it("weist eine übergeordnete Einheit ab, die es nicht gibt", () => { const r = pruefe( [blatt("Organisation", ORG_SPALTEN, [["1", "A", "Gesellschaft", ""], ["2", "B", "Bereich", "99"]])], LEERER_BESTAND ); expect(meldungen(r.fehler)).toContain("Organisation/3/Übergeordnet: Steht weder in der Datei noch im System."); }); it("weist eine Einheit ab, die sich selbst übergeordnet ist", () => { const r = pruefe( [blatt("Organisation", ORG_SPALTEN, [["1", "A", "Gesellschaft", ""], ["2", "B", "Bereich", "2"]])], LEERER_BESTAND ); expect(meldungen(r.fehler).join(" ")).toContain("kann sich nicht selbst übergeordnet sein"); }); it("nennt bei einer doppelten Orgnummer die frühere Zeile", () => { const r = pruefe( [ blatt("Organisation", ORG_SPALTEN, [ ["1", "A", "Gesellschaft", ""], ["2", "B", "Bereich", "1"], ["2", "C", "Bereich", "1"], ]), ], LEERER_BESTAND ); expect(meldungen(r.fehler).join(" ")).toContain("Kommt bereits in Zeile 3 vor"); }); }); describe("Personen", () => { it("lässt eine saubere Zeile durch", () => { const r = pruefe([blatt("Personen", PERSON_SPALTEN, [person("2219", "a@example.at", "60000001")])], BESTAND); expect(r.fehler).toEqual([]); expect(r.anzahl.Personen).toBe(1); }); it("verhindert, dass zwei Zeilen dieselbe Planstelle besetzen", () => { // Die Datenbank hat dafür einen Teilindex — ohne diese Prüfung bräche // der Import erst beim Schreiben ab, ohne Zeilennummer. const r = pruefe( [ blatt("Personen", PERSON_SPALTEN, [ person("1", "a@example.at", "60000001"), person("2", "b@example.at", "60000001"), ]), ], BESTAND ); expect(meldungen(r.fehler)).toContain("Personen/3/Planstellennummer: Wird bereits in Zeile 2 besetzt."); }); it("weist eine bereits besetzte Planstelle ab", () => { const r = pruefe([blatt("Personen", PERSON_SPALTEN, [person("1", "a@example.at", "60000009")])], BESTAND); expect(meldungen(r.fehler)).toContain("Personen/2/Planstellennummer: Diese Planstelle ist bereits besetzt."); }); it("prüft die SV-Nummer gegen das Geburtsdatum", () => { const spalten = [...PERSON_SPALTEN, "SV-Nummer"]; const gut = pruefe( [blatt("Personen", spalten, [[...person("1", "a@example.at", "60000001"), "7960 150868"]])], BESTAND ); expect(gut.fehler).toEqual([]); const falsch = pruefe( [blatt("Personen", spalten, [[...person("1", "a@example.at", "60000001"), "7960 010170"]])], BESTAND ); expect(falsch.fehler.some((f) => f.spalte === "SV-Nummer")).toBe(true); }); it("verlangt einen Grund zum Austritt und einen Austritt nach dem Eintritt", () => { const spalten = [...PERSON_SPALTEN, "Austritt", "Austrittsgrund"]; const ohneGrund = pruefe( [blatt("Personen", spalten, [[...person("1", "a@example.at", "60000001"), "01.01.2020", ""]])], BESTAND ); expect(meldungen(ohneGrund.fehler).join(" ")).toContain("Austrittsgrund"); const zuFrueh = pruefe( [blatt("Personen", spalten, [[...person("1", "a@example.at", "60000001"), "01.01.2010", "Kündigung"]])], BESTAND ); expect(meldungen(zuFrueh.fehler).join(" ")).toContain("Liegt vor dem Eintritt"); }); it("lässt Ein- und Austritt am selben Tag zu", () => { // Wer den Dienst nicht antritt, tritt am selben Tag ein und aus. Die // CHECK-Bedingung der Datenbank erlaubt das ausdrücklich; die Prüfung // hier war strenger und hätte einen echten Fall abgewiesen. const spalten = [...PERSON_SPALTEN, "Austritt", "Austrittsgrund"]; const r = pruefe( [blatt("Personen", spalten, [[...person("1", "a@example.at", "60000001"), "01.03.2015", "Dienstantritt nicht erfolgt"]])], BESTAND ); expect(r.fehler).toEqual([]); }); it("bindet die Wochenstunden an die Beschäftigungsart", () => { // Vollzeit sind genau 38,5 — die Datenbank hat dafür eine // CHECK-Bedingung. Ohne diese Prüfung bricht der Import erst beim // Schreiben ab, und die Meldung nennt keine Zeile. const spalten = [...PERSON_SPALTEN, "Beschäftigung", "Wochenstunden"]; const falsch = pruefe( [blatt("Personen", spalten, [[...person("1", "a@example.at", "60000001"), "Vollzeit", "20,5"]])], BESTAND ); expect(meldungen(falsch.fehler).join(" ")).toContain("Vollzeit sind genau 38,5"); const teilzeitOhne = pruefe( [blatt("Personen", spalten, [[...person("1", "a@example.at", "60000001"), "Teilzeit", ""]])], BESTAND ); expect(meldungen(teilzeitOhne.fehler).join(" ")).toContain("Pflicht bei Teilzeit"); const gut = pruefe( [blatt("Personen", spalten, [[...person("1", "a@example.at", "60000001"), "Teilzeit", "20,5"]])], BESTAND ); expect(gut.fehler).toEqual([]); }); it("lässt nur Titel aus der hinterlegten Liste zu", () => { const spalten = [...PERSON_SPALTEN, "Titel vorangestellt"]; const erfunden = pruefe( [blatt("Personen", spalten, [[...person("1", "a@example.at", "60000001"), "Doktor"]])], BESTAND ); expect(meldungen(erfunden.fehler).join(" ")).toContain("Doktor"); const echt = pruefe([blatt("Personen", spalten, [[...person("1", "a@example.at", "60000001"), "Mag."]])], BESTAND); expect(echt.fehler).toEqual([]); }); it("verlangt ein Ende nur bei befristeten Verträgen — und dort immer", () => { const spalten = [...PERSON_SPALTEN, "Vertragsart", "Befristet bis"]; const fehlt = pruefe( [blatt("Personen", spalten, [[...person("1", "a@example.at", "60000001"), "befristet", ""]])], BESTAND ); expect(meldungen(fehlt.fehler).join(" ")).toContain("Pflicht bei einem befristeten Vertrag"); const zuviel = pruefe( [blatt("Personen", spalten, [[...person("1", "a@example.at", "60000001"), "unbefristet", "01.01.2030"]])], BESTAND ); expect(meldungen(zuviel.fehler).join(" ")).toContain("Nur bei „befristet“ erlaubt"); }); it("meldet einen unbekannten Standort statt ihn anzulegen", () => { const r = pruefe( [blatt("Personen", PERSON_SPALTEN, [person("1", "a@example.at", "60000001", { Standort: "Graz" })])], BESTAND ); expect(meldungen(r.fehler)).toContain("Personen/2/Standort: Steht weder in der Datei noch im System."); }); it("sammelt alle Fehler, statt beim ersten aufzuhören", () => { // Bei 800 Zeilen ist das der Unterschied zwischen einmal korrigieren und // achthundertmal hochladen. const r = pruefe( [ blatt("Personen", PERSON_SPALTEN, [ person("1", "kaputt", "60000001"), person("1", "b@example.at", "99999999"), ]), ], BESTAND ); const zeilen = new Set(r.fehler.map((f) => f.zeile)); expect(zeilen.has(2)).toBe(true); expect(zeilen.has(3)).toBe(true); expect(r.fehler.length).toBeGreaterThanOrEqual(3); }); }); describe("Historie", () => { const H = ["Personalnummer", "Datum", "Ereignis", "Beschreibung"]; it("weist ein Ereignis vor dem Eintritt ab", () => { // Genau der Fall, an dem der Seed einmal stillschweigend gescheitert ist. const r = pruefe( [ blatt("Personen", PERSON_SPALTEN, [person("2219", "a@example.at", "60000001")]), blatt("Historie", H, [["2219", "01.01.2010", "Eintritt", "Eintritt"]]), ], BESTAND ); expect(meldungen(r.fehler)).toContain("Historie/2/Datum: Liegt vor dem Eintritt am 2015-03-01."); }); it("weist ein Ereignis zu einer unbekannten Person ab", () => { const r = pruefe([blatt("Historie", H, [["999", "01.01.2020", "Eintritt", "x"]])], BESTAND); expect(meldungen(r.fehler)).toContain("Historie/2/Personalnummer: Steht weder in der Datei noch im System."); }); it("weist einen unbekannten Ereignistyp ab und nennt die erlaubten", () => { const r = pruefe( [ blatt("Personen", PERSON_SPALTEN, [person("2219", "a@example.at", "60000001")]), blatt("Historie", H, [["2219", "01.04.2015", "Gehaltserhöhung", "x"]]), ], BESTAND ); const m = meldungen(r.fehler).join(" "); expect(m).toContain("Unbekannter Wert"); expect(m).toContain("Gehaltsanpassung"); }); }); describe("Blätter und Spalten", () => { it("meldet eine fehlende Pflichtspalte am Blatt statt an jeder Zeile", () => { const r = pruefe([blatt("Jobkatalog", ["Jobcode"], [["50000001"]])], LEERER_BESTAND); expect(meldungen(r.fehler)).toContain("Jobkatalog/-/Bezeichnung: Pflichtspalte fehlt."); }); it("übergeht ein unbekanntes Blatt mit Hinweis statt mit Fehler", () => { // Mappen enthalten oft ein „Deckblatt“ oder „Tabelle1“. Das ist kein // Grund, den ganzen Import zu verweigern. const r = pruefe([blatt("Deckblatt", ["A"], [["x"]])], LEERER_BESTAND); expect(r.fehler).toEqual([]); expect(meldungen(r.hinweise).join(" ")).toContain("Unbekanntes Blatt"); }); });