Read an import file without guessing what it means
First half of the mass import: a file becomes named sheets with typed rows,
and every rule that could reject a row is stated in one place.
Nothing here touches a database. The parser turns bytes into sheets, the
schema says which columns exist, and validation reports findings — the
existing state is passed in as a parameter. That is what makes 36 tests
possible without a connection, and the rules are the part worth testing.
Three decisions where the easy choice would have been silent corruption:
- A two-digit year is refused. "15.08.68" is 1968 as a birth date and 2068
as a contract end, and any rule invented here creates people not yet
born.
- "31.02.2026" is refused. Date turns it into March 3rd without complaint.
- An unrecognised value in a yes/no column is an error, not "no". Read the
other way, a typo in "Betriebsrat" quietly removes someone's dismissal
protection.
CSV is parsed rather than split. German Excel writes semicolons because the
comma is the decimal separator, so the delimiter is sniffed from the header;
a semicolon inside a quoted address would otherwise shift every following
column and import the row plausibly wrong. Quoted newlines, doubled quotes
and the byte-order mark Excel prepends are all handled — the last one makes
the first column read as "?Personalnummer", which is invisible in an editor.
Validation collects every finding instead of stopping at the first. With 800
rows that is the difference between correcting once and uploading eight
hundred times.
One rule earns its place from experience: a history event dated before the
entry it belongs to is refused here, with a row number, because the database
refuses it too — mid-insert, without one.
My own slip, caught by the type checker: `a ?? b ? c : d` does not mean what
it looks like; ?? binds tighter than the conditional.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
252
tests/unit/import-validate.test.ts
Normal file
252
tests/unit/import-validate.test.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
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 nicht nach dem Eintritt");
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user