Files
alpenwerk-hr/tests/unit/import-validate.test.ts
Maximilian Stubhan 3926f1bb80 Load a whole organisation from a file, or none of it
Second half of the mass import: the transactional loader, the /import page
and a template generated from the same schema the validation uses.

Everything happens in one transaction. A half-loaded organisation — areas
without departments, positions without people — is worse than none, because
it looks like data. The dry run is the same code path with a rollback at the
end, so the report is built against the real current state rather than a
copy, and nothing is cached between checking and committing: the file is
sent twice. That costs one upload and avoids server-side state that can
expire, fill up, or be confused between two people.

Personnel numbers are taken from the file, not reassigned. personnel_number
is GENERATED ALWAYS AS IDENTITY, so this needs OVERRIDING SYSTEM VALUE and a
hand-written insert — worth it, because the number is on payslips, in files
and on badges. An import that reissues it is not a migration. The identity
counter is advanced afterwards; without that the next hire draws a number
the import already used, and the unique index refuses it weeks later, far
from the cause.

Three defects the first real run against the database exposed, none of which
typecheck, lint or 231 tests could have found:

  - weekly_hours is bound to employment type by a CHECK constraint: full time
    is exactly 38.5. The import reached the insert and was rolled back. Now
    it is a finding with a row number.
  - Titles are restricted to a fixed list by another CHECK. Same treatment.
  - setval() needs UPDATE on the sequence, which `usage, select` does not
    grant. Migration 20260803120000 adds it; until it is applied, an import
    containing people will fail at the last step and take itself back.

I also had exit_date > entry_date where the database has >=. Someone who
never starts enters and leaves the same day; the stricter rule would have
rejected a real case.

Verified against the live database through the actual route and session: a
file with four deliberate faults produced exactly four findings, each with
sheet, row and column, and the rollback left nothing behind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 14:40:33 +02:00

301 lines
11 KiB
TypeScript

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<Record<string, string>> = {}) => [
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");
});
});