Files
Laufzettel/tests/mitarbeiter-import.test.ts
T
florian.wilde ae290345b7
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
feat(verzeichnis): LDAP-Abgleich, Zeitplan, CSV-Import und Log (#30)
2026-09-26 00:21:56 +00:00

431 lines
18 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { beforeEach, describe, expect, it } from "vitest";
import { Betriebsmodus, Herkunft } from "@/generated/prisma/enums";
import {
MITARBEITER_IMPORT_HOECHSTZEILEN,
mitarbeiterImportieren,
type MitarbeiterImportErgebnis,
} from "@/server/services/mitarbeiter";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
/**
* CSV-Import der Personen (#016).
*
* Festgehalten wird vor allem, was in #011 am Geräteimport schiefging: der
* Import meldete „3 von 3 angelegt“, obwohl Zeilen verschluckt worden waren.
* Deshalb prüft hier fast jeder Test die **Summe** mit — je Zeile genau ein Fach,
* und die Datenbank danach genauso, wie die Zahlen es behaupten.
*
* Dazu die zweite Regel des Hauses: was aus dem Verzeichnis kommt, wird nicht von
* Hand überschrieben.
*/
const KOPF = "givenName,sn,displayName,mail,upn,sam,department,title";
async function grundbestand(modus: Betriebsmodus = Betriebsmodus.INTERN) {
const eigene = await testPrisma.kunde.create({
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
});
await testPrisma.einstellung.create({ data: { id: "global", modus } });
const benutzer = await testPrisma.benutzer.create({
data: { email: "admin@firma.example", name: "Administrator", rolle: "ADMIN" },
});
return { eigene, admin: { id: benutzer.id, rolle: benutzer.rolle } };
}
/** Jede Zeile landet in genau einem Fach – sonst lügt die Erfolgsmeldung. */
function summePruefen(ergebnis: MitarbeiterImportErgebnis): void {
expect(ergebnis.angelegt + ergebnis.aktualisiert + ergebnis.unveraendert + ergebnis.abgewiesen.length).toBe(
ergebnis.gesamt,
);
}
describe.runIf(datenbankVerfuegbar)("Personen aus einer CSV-Datei", () => {
beforeEach(async () => {
await datenbankLeeren();
delete process.env["OIDC_CUSTOMER_GROUP_PREFIX"];
});
describe("Zeile für Zeile", () => {
it("legt die guten Zeilen an und meldet die kaputte mit ihrer Zeilennummer", async () => {
const { admin } = await grundbestand();
const text = [
KOPF,
"Anna,Muster,Anna Muster,anna@firma.example,anna@firma.example,amuster,Vertrieb,Innendienst",
'Bert,"Berg,Bert Berg,bert@firma.example,,bberg,Vertrieb,', // Anführungszeichen nicht geschlossen
"Clara,Cloud,Clara Cloud,clara@firma.example,,ccloud,Technik,",
",,,dora@firma.example,,dfehlt,,", // kein Name in der Zeile
"Emil,Eck,Emil Eck,,,eeck,,",
].join("\n");
const ergebnis = await mitarbeiterImportieren(text, admin);
expect(ergebnis.gesamt).toBe(5);
expect(ergebnis.angelegt).toBe(3);
summePruefen(ergebnis);
// Zeilennummern wie in der Datei: die Kopfzeile ist 1.
expect(ergebnis.abgewiesen.map((abweisung) => abweisung.zeile)).toEqual([3, 5]);
expect(ergebnis.abgewiesen[0]?.grund).toMatch(/Anführungszeichen/);
expect(ergebnis.abgewiesen[1]?.grund).toMatch(/fehlt ein Name/);
const namen = (await testPrisma.mitarbeiter.findMany({ select: { anzeigename: true } }))
.map((zeile) => zeile.anzeigename)
.sort();
expect(namen).toEqual(["Anna Muster", "Clara Cloud", "Emil Eck"]);
});
it("legt als MANUELL an und protokolliert die Zeilennummer", async () => {
// MANUELL, weil niemand aus dieser Datei im Verzeichnis steht — und weil
// eine so angelegte Person änderbar bleiben muss.
const { admin, eigene } = await grundbestand();
await mitarbeiterImportieren([KOPF, "Anna,Muster,,,,amuster,Vertrieb,"].join("\n"), admin);
const person = await testPrisma.mitarbeiter.findFirstOrThrow();
expect(person.herkunft).toBe(Herkunft.MANUELL);
expect(person.kundeId).toBe(eigene.id);
expect(person.anzeigename).toBe("Anna Muster");
expect(person.abteilung).toBe("Vertrieb");
const eintrag = await testPrisma.protokoll.findFirstOrThrow({
where: { aktion: "Mitarbeiter aus CSV angelegt" },
});
expect(eintrag.nachher).toMatchObject({ anzeigename: "Anna Muster", zeile: 2 });
});
it("versteht deutsches Excel: Semikolon, BOM und deutsche Spaltennamen", async () => {
const { admin } = await grundbestand();
const text =
"Vorname;Nachname;Anzeigename;Mail;UPN;sAMAccountName;Abteilung;Titel\r\n" +
'Jörg;Öz;"Öz, Jörg";joerg@firma.example;joerg@firma.example;joez;Technik;"Leiter ""IT"""\r\n';
const ergebnis = await mitarbeiterImportieren(text, admin);
expect(ergebnis.angelegt).toBe(1);
summePruefen(ergebnis);
await expect(testPrisma.mitarbeiter.findFirstOrThrow()).resolves.toMatchObject({
vorname: "Jörg",
nachname: "Öz",
anzeigename: "Öz, Jörg",
titel: 'Leiter "IT"',
});
});
it("teilt displayName am letzten Leerzeichen, lässt den Anzeigenamen aber stehen", async () => {
const { admin } = await grundbestand();
const ergebnis = await mitarbeiterImportieren(
["displayName,sam", "Anna van der Berg,aberg"].join("\n"),
admin,
);
expect(ergebnis.angelegt).toBe(1);
summePruefen(ergebnis);
// Die Teilung ist eine Annahme; der Anzeigename ist es nicht — und nur er
// steht später in der Oberfläche und im Laufzettel.
await expect(testPrisma.mitarbeiter.findFirstOrThrow()).resolves.toMatchObject({
vorname: "Anna van der",
nachname: "Berg",
anzeigename: "Anna van der Berg",
});
});
it("gibt die Regel des Dienstes auch zeilenweise weiter", async () => {
// Die 20-Zeichen-Grenze des sAMAccountName steht im Dienst und nirgends
// sonst. Der Import geht durch denselben Weg, also gilt sie hier auch.
const { admin } = await grundbestand();
const ergebnis = await mitarbeiterImportieren(
[KOPF, "Anna,Muster,,,,viel-zu-langer-anmeldename,,", "Bert,Berg,,,,bberg,,"].join("\n"),
admin,
);
expect(ergebnis.angelegt).toBe(1);
summePruefen(ergebnis);
expect(ergebnis.abgewiesen[0]).toMatchObject({ zeile: 2 });
expect(ergebnis.abgewiesen[0]?.grund).toMatch(/höchstens 20/);
});
it("weist eine Datei ohne Namensspalte als Ganzes ab", async () => {
const { admin } = await grundbestand();
await expect(mitarbeiterImportieren("mail,sam\nanna@firma.example,amuster\n", admin)).rejects.toThrow(
/displayName/,
);
expect(await testPrisma.mitarbeiter.count()).toBe(0);
});
it("bricht bei zu vielen Zeilen ab, bevor eine einzige geschrieben ist", async () => {
const { admin } = await grundbestand();
const zeilen = Array.from(
{ length: MITARBEITER_IMPORT_HOECHSTZEILEN + 1 },
(_, index) => `Anna,Muster${index},,,,a${index},,`,
);
await expect(mitarbeiterImportieren([KOPF, ...zeilen].join("\n"), admin)).rejects.toThrow(
new RegExp(`mehr als ${MITARBEITER_IMPORT_HOECHSTZEILEN} Zeilen`),
);
expect(await testPrisma.mitarbeiter.count()).toBe(0);
});
});
describe("Wiedererkennen statt verdoppeln", () => {
it("erkennt ein Duplikat innerhalb derselben Datei und nennt die erste Zeile", async () => {
const { admin } = await grundbestand();
const ergebnis = await mitarbeiterImportieren(
[
KOPF,
"Anna,Muster,,anna@firma.example,,amuster,Vertrieb,",
"Bert,Berg,,bert@firma.example,,bberg,Vertrieb,",
"Anna,Muster-Neu,,anna@firma.example,,AMUSTER,Vertrieb,", // dieselbe Kennung
].join("\n"),
admin,
);
expect(ergebnis.angelegt).toBe(2);
summePruefen(ergebnis);
expect(ergebnis.abgewiesen[0]?.zeile).toBe(4);
expect(ergebnis.abgewiesen[0]?.grund).toMatch(/schon in Zeile 2/);
expect(await testPrisma.mitarbeiter.count()).toBe(2);
// Und die erste Zeile gilt, nicht die dritte.
await expect(
testPrisma.mitarbeiter.findFirstOrThrow({ where: { sam: "amuster" } }),
).resolves.toMatchObject({ nachname: "Muster" });
});
it("legt dieselbe Datei beim zweiten Einlesen nicht erneut an", async () => {
// Der eigentliche Zweck der Wiedererkennung: eine Onboarding-Liste wandert
// mehrfach durch dieses Feld, und niemand zählt vorher nach.
const { admin } = await grundbestand();
const text = [
KOPF,
"Anna,Muster,Anna Muster,anna@firma.example,anna@firma.example,amuster,Vertrieb,Innendienst",
"Bert,Berg,Bert Berg,bert@firma.example,bert@firma.example,bberg,Technik,",
].join("\n");
const erster = await mitarbeiterImportieren(text, admin);
expect(erster.angelegt).toBe(2);
const zweiter = await mitarbeiterImportieren(text, admin);
expect(zweiter).toMatchObject({ gesamt: 2, angelegt: 0, aktualisiert: 0, unveraendert: 2 });
summePruefen(zweiter);
expect(await testPrisma.mitarbeiter.count()).toBe(2);
});
it("erkennt über den UPN, wenn kein sam in der Datei steht", async () => {
const { admin, eigene } = await grundbestand();
await testPrisma.mitarbeiter.create({
data: {
kundeId: eigene.id,
vorname: "Anna",
nachname: "Muster",
anzeigename: "Anna Muster",
upn: "anna.muster@firma.example",
sam: "amuster",
},
});
const ergebnis = await mitarbeiterImportieren(
["givenName,sn,upn,department", "Anna,Muster,ANNA.MUSTER@firma.example,Vertrieb"].join("\n"),
admin,
);
expect(ergebnis).toMatchObject({ angelegt: 0, aktualisiert: 1 });
summePruefen(ergebnis);
const person = await testPrisma.mitarbeiter.findFirstOrThrow();
expect(person.abteilung).toBe("Vertrieb");
// Gross- und Kleinschreibung unterscheidet keine Personen, deshalb bleibt
// der vorhandene Wert stehen, statt ihn zu überschreiben.
expect(person.upn).toBe("anna.muster@firma.example");
});
it("erkennt ohne sam und ohne UPN über den Anzeigenamen", async () => {
// Die Onboarding-Liste mit Name und Abteilung und sonst nichts. Ohne diesen
// Rückfall stünde jede Person nach dem zweiten Einlesen doppelt da.
const { admin } = await grundbestand();
const text = ["displayName,department", "Anna Muster,Vertrieb"].join("\n");
expect(await mitarbeiterImportieren(text, admin)).toMatchObject({ angelegt: 1 });
expect(await mitarbeiterImportieren(text, admin)).toMatchObject({ angelegt: 0, unveraendert: 1 });
expect(await testPrisma.mitarbeiter.count()).toBe(1);
});
it("weist eine Zeile ab, die auf zwei Personen passt", async () => {
// Hier zu raten hieße, die Stammdaten der falschen Person zu überschreiben.
const { admin, eigene } = await grundbestand();
for (const [vorname, sam, upn] of [
["Anna", "amuster", "a.muster@firma.example"],
["Anneliese", "amuster2", "anna@firma.example"],
] as const) {
await testPrisma.mitarbeiter.create({
data: { kundeId: eigene.id, vorname, nachname: "Muster", anzeigename: vorname, sam, upn },
});
}
const ergebnis = await mitarbeiterImportieren(
[KOPF, "Anna,Muster,,,anna@firma.example,amuster,Vertrieb,"].join("\n"),
admin,
);
expect(ergebnis).toMatchObject({ angelegt: 0, aktualisiert: 0, unveraendert: 0 });
summePruefen(ergebnis);
expect(ergebnis.abgewiesen[0]?.grund).toMatch(/passt auf/);
expect(await testPrisma.mitarbeiter.count()).toBe(2);
});
it("aktiviert eine deaktivierte Person nicht wieder", async () => {
// Das Wiedereinstellen ist eine Entscheidung und keine Zeile in einer
// Datei. Die Oberfläche sagt es genauso — also muss es hier auch gelten.
const { admin, eigene } = await grundbestand();
await testPrisma.mitarbeiter.create({
data: {
kundeId: eigene.id,
vorname: "Anna",
nachname: "Muster",
anzeigename: "Anna Muster",
sam: "amuster",
aktiv: false,
},
});
const ergebnis = await mitarbeiterImportieren(
[KOPF, "Anna,Muster,,,,amuster,Vertrieb,"].join("\n"),
admin,
);
expect(ergebnis).toMatchObject({ angelegt: 0, aktualisiert: 1 });
summePruefen(ergebnis);
await expect(testPrisma.mitarbeiter.findFirstOrThrow()).resolves.toMatchObject({
aktiv: false,
abteilung: "Vertrieb",
});
});
it("lässt eine leere Zelle nichts löschen", async () => {
const { admin, eigene } = await grundbestand();
await testPrisma.mitarbeiter.create({
data: {
kundeId: eigene.id,
vorname: "Anna",
nachname: "Muster",
anzeigename: "Muster, Anna",
sam: "amuster",
titel: "Innendienst",
mail: "anna@firma.example",
},
});
const ergebnis = await mitarbeiterImportieren(
[KOPF, "Anna,Muster,,,,amuster,Vertrieb,"].join("\n"),
admin,
);
expect(ergebnis).toMatchObject({ aktualisiert: 1 });
summePruefen(ergebnis);
await expect(testPrisma.mitarbeiter.findFirstOrThrow()).resolves.toMatchObject({
abteilung: "Vertrieb",
titel: "Innendienst",
mail: "anna@firma.example",
// Kein displayName in der Zeile heißt: der vorhandene bleibt, auch wenn
// er anders lautet als „Vorname Nachname“.
anzeigename: "Muster, Anna",
});
});
});
describe("Herkunft ABGLEICH", () => {
it("überschreibt eine Person aus dem Verzeichnis nicht, sondern meldet die Zeile", async () => {
// Der nächste Abgleich drehte die Änderung ohnehin zurück. Stillschweigend
// zu überspringen wäre das Schlimmere: der Import meldete dann einen
// Erfolg, den es nicht gab.
const { admin, eigene } = await grundbestand();
await testPrisma.mitarbeiter.create({
data: {
kundeId: eigene.id,
vorname: "Maria",
nachname: "Huber",
anzeigename: "Maria Huber",
sam: "mhuber",
abteilung: "Buchhaltung",
titel: "Leitung",
herkunft: Herkunft.ABGLEICH,
},
});
const protokollVorher = await testPrisma.protokoll.count();
const ergebnis = await mitarbeiterImportieren(
[KOPF, "Maria,Huber-Neu,,neu@firma.example,,mhuber,Vertrieb,Sachbearbeitung"].join("\n"),
admin,
);
expect(ergebnis).toMatchObject({ gesamt: 1, angelegt: 0, aktualisiert: 0, unveraendert: 0 });
summePruefen(ergebnis);
expect(ergebnis.abgewiesen[0]?.zeile).toBe(2);
expect(ergebnis.abgewiesen[0]?.grund).toMatch(/kommt aus dem Verzeichnis/);
await expect(testPrisma.mitarbeiter.findFirstOrThrow()).resolves.toMatchObject({
nachname: "Huber",
abteilung: "Buchhaltung",
titel: "Leitung",
mail: null,
});
expect(await testPrisma.protokoll.count()).toBe(protokollVorher);
});
});
describe("Mandantengrenze", () => {
it("verwirft im Modus INTERN eine mitgeschickte Kundenkennung", async () => {
const { admin, eigene } = await grundbestand();
const fremder = await testPrisma.kunde.create({ data: { name: "Fremder", kuerzel: "FREMD" } });
await mitarbeiterImportieren([KOPF, "Anna,Muster,,,,amuster,,"].join("\n"), admin, fremder.id);
await expect(testPrisma.mitarbeiter.findFirstOrThrow()).resolves.toMatchObject({
kundeId: eigene.id,
});
});
it("erkennt nur innerhalb desselben Kunden – und schreibt nicht für einen fremden", async () => {
await grundbestand(Betriebsmodus.DIENSTLEISTUNG);
const meiner = await testPrisma.kunde.create({ data: { name: "Meiner", kuerzel: "MEIN" } });
const fremder = await testPrisma.kunde.create({ data: { name: "Fremder", kuerzel: "FREMD" } });
const techniker = await testPrisma.benutzer.create({
data: { email: "t@firma.example", name: "Techniker", rolle: "TECHNIKER" },
});
await testPrisma.benutzerKunde.create({ data: { benutzerId: techniker.id, kundeId: meiner.id } });
process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "lz-kunde-";
const handelnder = { id: techniker.id, rolle: techniker.rolle };
// Dieselbe Kennung gibt es beim fremden Kunden schon. Sie darf hier nichts
// treffen: sonst schriebe dieser Import in fremde Stammdaten.
await testPrisma.mitarbeiter.create({
data: {
kundeId: fremder.id,
vorname: "Anna",
nachname: "Fremd",
anzeigename: "Anna Fremd",
sam: "amuster",
},
});
const ergebnis = await mitarbeiterImportieren(
[KOPF, "Anna,Muster,,,,amuster,Vertrieb,"].join("\n"),
handelnder,
meiner.id,
);
expect(ergebnis).toMatchObject({ angelegt: 1 });
summePruefen(ergebnis);
await expect(
testPrisma.mitarbeiter.findFirstOrThrow({ where: { kundeId: fremder.id } }),
).resolves.toMatchObject({ nachname: "Fremd", abteilung: null });
// Und für einen Kunden, der diesem Techniker nicht gehört, geht gar nichts.
await expect(
mitarbeiterImportieren([KOPF, "Bert,Berg,,,,bberg,,"].join("\n"), handelnder, fremder.id),
).rejects.toThrow(/Berechtigung/);
expect(await testPrisma.mitarbeiter.count({ where: { kundeId: fremder.id } })).toBe(1);
});
it("verlangt im Modus DIENSTLEISTUNG eine Kundenwahl", async () => {
const { admin } = await grundbestand(Betriebsmodus.DIENSTLEISTUNG);
await expect(
mitarbeiterImportieren([KOPF, "Anna,Muster,,,,amuster,,"].join("\n"), admin),
).rejects.toThrow(/Kunde/);
expect(await testPrisma.mitarbeiter.count()).toBe(0);
});
});
});