Files
Laufzettel/tests/mitarbeiter.test.ts
T
florian.wilde 0060a55387
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
feat(mitarbeiter): Vorgesetzter auch fuer Personen ohne Verzeichnis (#43)
2026-10-04 15:10:34 +00:00

525 lines
22 KiB
TypeScript

import { beforeEach, describe, expect, it } from "vitest";
import { Betriebsmodus, Herkunft } from "@/generated/prisma/enums";
import {
ABGEGLICHENE_FELDER,
MitarbeiterFehler,
mitarbeiterAktivSetzen,
mitarbeiterAnlegen,
mitarbeiterBearbeiten,
vorgesetztenSetzen,
} from "@/server/services/mitarbeiter";
import {
abteilungsNamen,
geraeteDesMitarbeiters,
mitarbeiterHolen,
mitarbeiterListe,
moeglicheVorgesetzte,
vorgesetzterVon,
} from "@/server/queries/mitarbeiter";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
/**
* Mitarbeiter (#011).
*
* Zwei Regeln tragen das Ganze: nichts wird geloescht, und was aus dem
* Verzeichnis kommt, wird nicht von Hand ueberschrieben. Alles hier dreht sich
* darum — dazu die Mandantensicht, die im Review von #009 dreimal die Fundstelle
* war.
*/
async function grundbestand() {
const eigene = await testPrisma.kunde.create({
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
});
await testPrisma.einstellung.create({ data: { id: "global", modus: Betriebsmodus.INTERN } });
const admin = await testPrisma.benutzer.create({
data: { email: "admin@firma.example", name: "Admin", rolle: "ADMIN" },
});
return { eigene, admin: { id: admin.id, rolle: admin.rolle } };
}
describe.runIf(datenbankVerfuegbar)("Mitarbeiter", () => {
beforeEach(async () => {
await datenbankLeeren();
});
describe("Vorgesetzter", () => {
it("laesst sich fuer eine von Hand angelegte Person setzen", async () => {
// Der Fall, der vorher gar nicht ging: ohne Verzeichnisquelle gab es kein
// Feld fuer die Zuordnung (#020).
const { admin } = await grundbestand();
const maria = await mitarbeiterAnlegen({ vorname: "Maria", nachname: "Huber" }, admin);
const petra = await mitarbeiterAnlegen({ vorname: "Petra", nachname: "Klein" }, admin);
expect(maria.herkunft).toBe(Herkunft.MANUELL);
await vorgesetztenSetzen(maria.id, petra.id, admin);
expect(await vorgesetzterVon(maria.id, admin)).toEqual({
id: petra.id,
anzeigename: "Petra Klein",
});
});
it("entfernt die Zuordnung bei leerer Angabe", async () => {
const { admin } = await grundbestand();
const maria = await mitarbeiterAnlegen({ vorname: "Maria", nachname: "Huber" }, admin);
const petra = await mitarbeiterAnlegen({ vorname: "Petra", nachname: "Klein" }, admin);
await vorgesetztenSetzen(maria.id, petra.id, admin);
await vorgesetztenSetzen(maria.id, "", admin);
expect(await vorgesetzterVon(maria.id, admin)).toBeUndefined();
});
it("laesst niemanden sein eigener Vorgesetzter sein", async () => {
const { admin } = await grundbestand();
const maria = await mitarbeiterAnlegen({ vorname: "Maria", nachname: "Huber" }, admin);
await expect(vorgesetztenSetzen(maria.id, maria.id, admin)).rejects.toThrow(/eigener Vorgesetzter/);
});
it("weist eine Schleife ueber zwei Personen ab", async () => {
const { admin } = await grundbestand();
const a = await mitarbeiterAnlegen({ vorname: "A", nachname: "Eins" }, admin);
const b = await mitarbeiterAnlegen({ vorname: "B", nachname: "Zwei" }, admin);
await vorgesetztenSetzen(b.id, a.id, admin);
await expect(vorgesetztenSetzen(a.id, b.id, admin)).rejects.toThrow(/Schleife/);
});
it("weist eine Schleife ueber drei Personen ab", async () => {
// Die Datenbank faengt nur die kuerzeste ab; diese braucht den Gang nach
// oben im Dienst.
const { admin } = await grundbestand();
const a = await mitarbeiterAnlegen({ vorname: "A", nachname: "Eins" }, admin);
const b = await mitarbeiterAnlegen({ vorname: "B", nachname: "Zwei" }, admin);
const c = await mitarbeiterAnlegen({ vorname: "C", nachname: "Drei" }, admin);
await vorgesetztenSetzen(b.id, a.id, admin);
await vorgesetztenSetzen(c.id, b.id, admin);
await expect(vorgesetztenSetzen(a.id, c.id, admin)).rejects.toThrow(/Schleife/);
});
it("gilt nur innerhalb desselben Kunden", async () => {
const { eigene, admin } = await grundbestand();
const fremd = await testPrisma.kunde.create({ data: { name: "Fremd GmbH", kuerzel: "FREMD" } });
const hier = await testPrisma.mitarbeiter.create({
data: { kundeId: eigene.id, vorname: "Maria", nachname: "Huber", anzeigename: "Maria Huber" },
});
const dort = await testPrisma.mitarbeiter.create({
data: { kundeId: fremd.id, vorname: "Petra", nachname: "Klein", anzeigename: "Petra Klein" },
});
await expect(vorgesetztenSetzen(hier.id, dort.id, admin)).rejects.toThrow(/selben Kunden/);
// Und auch an der Service-Schicht vorbei nicht: der zusammengesetzte
// Fremdschluessel haelt dieselbe Grenze.
await expect(
testPrisma.mitarbeiter.update({
where: { id: hier.id },
data: { vorgesetzterId: dort.id },
}),
).rejects.toThrow(/Mitarbeiter_vorgesetzter_gleicher_kunde/);
});
it("bietet zur Auswahl nur aktive Kollegen desselben Kunden, ohne die Person selbst", async () => {
const { eigene, admin } = await grundbestand();
const fremd = await testPrisma.kunde.create({ data: { name: "Fremd GmbH", kuerzel: "FREMD" } });
const maria = await mitarbeiterAnlegen({ vorname: "Maria", nachname: "Huber" }, admin);
const petra = await mitarbeiterAnlegen({ vorname: "Petra", nachname: "Klein" }, admin);
const weg = await mitarbeiterAnlegen({ vorname: "Alt", nachname: "Kollege" }, admin);
await mitarbeiterAktivSetzen(weg.id, false, admin);
await testPrisma.mitarbeiter.create({
data: { kundeId: fremd.id, vorname: "Fremd", nachname: "Person", anzeigename: "Fremd Person" },
});
expect(eigene.id).toBe(maria.kundeId);
const auswahl = await moeglicheVorgesetzte(maria.id, admin);
expect(auswahl.map((eintrag) => eintrag.id)).toEqual([petra.id]);
});
it("schreibt die Aenderung ins Protokoll, mit Namen", async () => {
const { admin } = await grundbestand();
const maria = await mitarbeiterAnlegen({ vorname: "Maria", nachname: "Huber" }, admin);
const petra = await mitarbeiterAnlegen({ vorname: "Petra", nachname: "Klein" }, admin);
await vorgesetztenSetzen(maria.id, petra.id, admin);
await vorgesetztenSetzen(maria.id, null, admin);
const eintraege = await testPrisma.protokoll.findMany({
where: { entitaet: "Mitarbeiter", entitaetId: maria.id },
orderBy: { erstelltAm: "asc" },
select: { aktion: true },
});
// Das Anlegen protokolliert selbst - hier zaehlt, was danach kam.
expect(eintraege.map((e) => e.aktion).slice(1)).toEqual([
"Vorgesetzter auf Petra Klein gesetzt",
"Vorgesetzter entfernt",
]);
});
});
describe("Anlegen", () => {
it("bildet den Anzeigenamen aus Vor- und Nachname, wenn keiner angegeben ist", async () => {
const { admin } = await grundbestand();
const person = await mitarbeiterAnlegen({ vorname: " Maria ", nachname: " Huber " }, admin);
expect(person.anzeigename).toBe("Maria Huber");
expect(person.herkunft).toBe(Herkunft.MANUELL);
expect(person.aktiv).toBe(true);
});
it("nimmt einen eigenen Anzeigenamen an", async () => {
const { admin } = await grundbestand();
const person = await mitarbeiterAnlegen(
{ vorname: "Maria", nachname: "Huber", anzeigename: "Huber, Maria" },
admin,
);
expect(person.anzeigename).toBe("Huber, Maria");
});
it("weist einen leeren Namen ab", async () => {
const { admin } = await grundbestand();
await expect(mitarbeiterAnlegen({ vorname: " ", nachname: "Huber" }, admin)).rejects.toThrow(
MitarbeiterFehler,
);
expect(await testPrisma.mitarbeiter.count()).toBe(0);
});
it("weist einen sAMAccountName über 20 Zeichen ab", async () => {
// Das Active Directory kuerzt nicht, es lehnt ab — und zwar erst beim
// New-ADUser vor dem Kunden.
const { admin } = await grundbestand();
await expect(
mitarbeiterAnlegen({ vorname: "Jörg", nachname: "Müller", sam: "j".repeat(21) }, admin),
).rejects.toThrow(/höchstens 20/);
await expect(
mitarbeiterAnlegen({ vorname: "Jörg", nachname: "Müller", sam: "j".repeat(20) }, admin),
).resolves.toBeTruthy();
});
it("schreibt einen Protokolleintrag", async () => {
const { admin } = await grundbestand();
const person = await mitarbeiterAnlegen({ vorname: "Maria", nachname: "Huber" }, admin);
const eintrag = await testPrisma.protokoll.findFirstOrThrow({
where: { entitaetId: person.id },
});
expect(eintrag.aktion).toBe("Mitarbeiter angelegt");
expect(eintrag.benutzerId).toBe(admin.id);
});
});
describe("Aus dem Verzeichnis abgeglichene Personen", () => {
async function abgeglichen(kundeId: string) {
return testPrisma.mitarbeiter.create({
data: {
kundeId,
vorname: "Maria",
nachname: "Huber",
anzeigename: "Maria Huber",
mail: "maria.huber@firma.example",
abteilung: "Buchhaltung",
herkunft: Herkunft.ABGLEICH,
},
});
}
it("lässt abgeglichene Felder nicht von Hand ändern", async () => {
// Der naechste Abgleich wuerde die Aenderung ohnehin zurueckdrehen. Ein
// Wert, der zwischen zwei Laeufen hin- und herspringt, ist schlimmer als
// ein falscher, weil niemand ihm mehr traut.
const { admin, eigene } = await grundbestand();
const person = await abgeglichen(eigene.id);
for (const aenderung of [
{ vorname: "Marie" },
{ nachname: "Hubert" },
{ mail: "anders@firma.example" },
{ abteilung: "Vertrieb" },
]) {
await expect(
mitarbeiterBearbeiten(person.id, aenderung, admin),
JSON.stringify(aenderung),
).rejects.toThrow(/Verzeichnis/);
}
});
it("stört sich nicht an unverändert mitgeschickten Feldern", async () => {
// Das Formular schickt alle Felder mit. Wuerde schon das Mitschicken
// scheitern, liefe der Benutzer gegen eine Fehlermeldung, ohne etwas
// geaendert zu haben. Abgewiesen wird nur, was sich wirklich aendern soll.
const { admin, eigene } = await grundbestand();
const person = await abgeglichen(eigene.id);
const vorherProtokoll = await testPrisma.protokoll.count();
const nachher = await mitarbeiterBearbeiten(
person.id,
{
vorname: "Maria",
nachname: "Huber",
anzeigename: "Maria Huber",
mail: "maria.huber@firma.example",
abteilung: "Buchhaltung",
},
admin,
);
expect(nachher.anzeigename).toBe("Maria Huber");
expect(await testPrisma.protokoll.count()).toBe(vorherProtokoll);
});
it("sperrt bei ABGLEICH jedes gepflegte Feld, nicht nur einige", async () => {
// Alle Stammdaten einer abgeglichenen Person kommen aus dem Verzeichnis —
// auch der Titel. Uebrig bleibt genau ein Weg: aktivieren und
// deaktivieren. Das ist Absicht und keine Luecke.
const { admin, eigene } = await grundbestand();
const person = await abgeglichen(eigene.id);
for (const feld of ABGEGLICHENE_FELDER) {
await expect(
mitarbeiterBearbeiten(person.id, { [feld]: `anders-${feld}` }, admin),
feld,
).rejects.toThrow(/Verzeichnis/);
}
});
it("lässt eine manuell angelegte Person vollständig ändern", async () => {
const { admin } = await grundbestand();
const person = await mitarbeiterAnlegen({ vorname: "Maria", nachname: "Huber" }, admin);
const nachher = await mitarbeiterBearbeiten(
person.id,
{ vorname: "Marie", mail: "marie@firma.example" },
admin,
);
expect(nachher.vorname).toBe("Marie");
expect(nachher.mail).toBe("marie@firma.example");
});
it("lässt sich auch bei Herkunft ABGLEICH deaktivieren", async () => {
// Das Offboarding muss auch dann gehen, wenn das Verzeichnis noch nicht
// nachgezogen hat.
const { admin, eigene } = await grundbestand();
const person = await abgeglichen(eigene.id);
const nachher = await mitarbeiterAktivSetzen(person.id, false, admin);
expect(nachher.aktiv).toBe(false);
});
});
describe("Bearbeiten", () => {
it("schreibt nichts, wenn sich nichts ändert", async () => {
const { admin } = await grundbestand();
const person = await mitarbeiterAnlegen({ vorname: "Maria", nachname: "Huber" }, admin);
const vorher = await testPrisma.protokoll.count();
await mitarbeiterBearbeiten(person.id, { vorname: "Maria", nachname: "Huber" }, admin);
expect(await testPrisma.protokoll.count()).toBe(vorher);
});
it("nennt im Protokoll, was sich geändert hat", async () => {
const { admin } = await grundbestand();
const person = await mitarbeiterAnlegen({ vorname: "Maria", nachname: "Huber" }, admin);
await mitarbeiterBearbeiten(person.id, { mail: "m.huber@firma.example" }, admin);
const eintrag = await testPrisma.protokoll.findFirstOrThrow({
where: { entitaetId: person.id, aktion: { contains: "geändert" } },
});
expect(eintrag.aktion).toBe("Mail-Adresse geändert");
expect(eintrag.vorher).toEqual({ mail: null });
expect(eintrag.nachher).toEqual({ mail: "m.huber@firma.example" });
});
});
describe("Deaktivieren statt löschen", () => {
it("setzt aktiv auf false und protokolliert es", async () => {
const { admin } = await grundbestand();
const person = await mitarbeiterAnlegen({ vorname: "Maria", nachname: "Huber" }, admin);
await mitarbeiterAktivSetzen(person.id, false, admin);
expect((await testPrisma.mitarbeiter.findUniqueOrThrow({ where: { id: person.id } })).aktiv).toBe(
false,
);
const eintrag = await testPrisma.protokoll.findFirstOrThrow({
where: { entitaetId: person.id, aktion: "Mitarbeiter deaktiviert" },
});
expect(eintrag).toBeTruthy();
});
it("bietet gar keinen Löschweg an", async () => {
// Waere Loeschen manchmal moeglich, muesste jeder Aufrufer die Bedingung
// kennen — und beim Offboarding entscheidet sich niemand bewusst zwischen
// zwei Wegen. Geprueft wird der Dienst selbst: was er nicht anbietet, kann
// auch niemand versehentlich rufen.
const dienst = await import("@/server/services/mitarbeiter");
const verdaechtig = Object.keys(dienst).filter((name) => /loesch|entfern|delete/iu.test(name));
expect(verdaechtig).toEqual([]);
expect(Object.keys(dienst)).toContain("mitarbeiterAktivSetzen");
});
});
describe("Liste und Suche", () => {
async function bestand() {
const { admin, eigene } = await grundbestand();
await mitarbeiterAnlegen(
{
vorname: "Maria",
nachname: "Huber",
mail: "maria.huber@firma.example",
upn: "maria.huber@firma.example",
sam: "mhuber",
abteilung: "Buchhaltung",
},
admin,
);
const jens = await mitarbeiterAnlegen(
{ vorname: "Jens", nachname: "Ex", sam: "jex", abteilung: "Vertrieb" },
admin,
);
await mitarbeiterAktivSetzen(jens.id, false, admin);
return { admin, eigene, jens };
}
it("zeigt standardmäßig nur aktive Personen", async () => {
const { admin } = await bestand();
const namen = (await mitarbeiterListe(admin)).map((z) => z.anzeigename);
expect(namen).toEqual(["Maria Huber"]);
});
it("zeigt mit Umschalter auch deaktivierte", async () => {
const { admin } = await bestand();
const namen = (await mitarbeiterListe(admin, { auchDeaktivierte: true })).map((z) => z.anzeigename);
expect(namen.sort()).toEqual(["Jens Ex", "Maria Huber"]);
});
it("sucht über Anzeigename, Mail, UPN und sam", async () => {
const { admin } = await bestand();
for (const suche of ["huber", "MARIA.HUBER@firma", "mhuber"]) {
const treffer = await mitarbeiterListe(admin, { suche });
expect(
treffer.map((z) => z.anzeigename),
suche,
).toEqual(["Maria Huber"]);
}
});
it("lässt den Aktiv-Filter auch beim Suchen stehen", async () => {
// Genau der Blocker aus #009 in neuem Gewand: der Suchfilter bringt ein
// eigenes `OR` mit. Liegt es nicht in einem `AND`, gewinnt es.
const { admin } = await bestand();
const treffer = await mitarbeiterListe(admin, { suche: "e" });
expect(treffer.map((z) => z.anzeigename)).toEqual(["Maria Huber"]);
});
it("filtert nach Abteilung", async () => {
const { admin } = await bestand();
expect((await mitarbeiterListe(admin, { abteilung: "Buchhaltung" })).length).toBe(1);
expect((await mitarbeiterListe(admin, { abteilung: "Vertrieb" })).length).toBe(0);
});
it("liefert die vorkommenden Abteilungsnamen", async () => {
const { admin } = await bestand();
expect(await abteilungsNamen(admin)).toEqual(["Buchhaltung", "Vertrieb"]);
});
it("zählt Geräte und Läufe", async () => {
const { admin, eigene } = await bestand();
const person = (await mitarbeiterListe(admin))[0]!;
await testPrisma.geraet.create({
data: { kundeId: eigene.id, mitarbeiterId: person.id, tagCode: "LZ-7K3F9Q", hostname: "PC-001" },
});
const frisch = (await mitarbeiterListe(admin))[0]!;
expect(frisch.anzahlGeraete).toBe(1);
expect(frisch.anzahlLaeufe).toBe(0);
});
it("gibt die Geräte einer Person zurück, Hostname vor Bezeichnung", async () => {
const { admin, eigene } = await bestand();
const person = (await mitarbeiterListe(admin))[0]!;
await testPrisma.geraet.create({
data: { kundeId: eigene.id, mitarbeiterId: person.id, tagCode: "LZ-7K3F9Q", hostname: "PC-001" },
});
await testPrisma.geraet.create({
data: {
kundeId: eigene.id,
mitarbeiterId: person.id,
tagCode: "LZ-8M4G2R",
bezeichnung: "Monitor rechts",
},
});
const geraete = await geraeteDesMitarbeiters(person.id, admin);
expect(geraete.map((g) => g.name).sort()).toEqual(["Monitor rechts", "PC-001"]);
});
});
describe("Mandantensicht", () => {
async function zweiKunden() {
const eigene = await testPrisma.kunde.create({
data: { name: "Eigene", kuerzel: "EIGEN", istEigeneOrganisation: true },
});
const meiner = await testPrisma.kunde.create({ data: { name: "Meiner", kuerzel: "MEIN" } });
const fremder = await testPrisma.kunde.create({ data: { name: "Fremder", kuerzel: "FREMD" } });
await testPrisma.einstellung.create({
data: { id: "global", modus: Betriebsmodus.DIENSTLEISTUNG },
});
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-";
for (const [kundeId, name] of [
[meiner.id, "Meine Person"],
[fremder.id, "Fremde Person"],
] as const) {
await testPrisma.mitarbeiter.create({
data: { kundeId, vorname: name, nachname: "X", anzeigename: name, abteilung: "Geheim" },
});
}
return {
eigene,
techniker: { id: techniker.id, rolle: techniker.rolle },
fremdePerson: await testPrisma.mitarbeiter.findFirstOrThrow({ where: { kundeId: fremder.id } }),
};
}
it("zeigt einem gebundenen Techniker keine fremde Person", async () => {
const { techniker } = await zweiKunden();
expect((await mitarbeiterListe(techniker)).map((z) => z.anzeigename)).toEqual(["Meine Person"]);
});
it("hält die Grenze auch beim Suchen", async () => {
const { techniker } = await zweiKunden();
expect((await mitarbeiterListe(techniker, { suche: "Person" })).map((z) => z.anzeigename)).toEqual([
"Meine Person",
]);
});
it("gibt eine fremde Person nicht einzeln heraus", async () => {
const { techniker, fremdePerson } = await zweiKunden();
expect(await mitarbeiterHolen(fremdePerson.id, techniker)).toBeUndefined();
});
it("zeigt die Geräte einer fremden Person nicht", async () => {
const { techniker, fremdePerson } = await zweiKunden();
await testPrisma.geraet.create({
data: { kundeId: fremdePerson.kundeId, mitarbeiterId: fremdePerson.id, tagCode: "LZ-7K3F9Q" },
});
expect(await geraeteDesMitarbeiters(fremdePerson.id, techniker)).toEqual([]);
});
it("blendet fremde Abteilungsnamen aus dem Filter aus", async () => {
const { techniker } = await zweiKunden();
await testPrisma.mitarbeiter.updateMany({
where: { anzeigename: "Fremde Person" },
data: { abteilung: "Nur-fuer-Fremde" },
});
expect(await abteilungsNamen(techniker)).not.toContain("Nur-fuer-Fremde");
});
it("lässt eine fremde Person nicht deaktivieren", async () => {
const { techniker, fremdePerson } = await zweiKunden();
await expect(mitarbeiterAktivSetzen(fremdePerson.id, false, techniker)).rejects.toThrow(/Berechtigung/);
});
it("lässt eine fremde Person nicht bearbeiten", async () => {
const { techniker, fremdePerson } = await zweiKunden();
await expect(mitarbeiterBearbeiten(fremdePerson.id, { titel: "Chef" }, techniker)).rejects.toThrow(
/Berechtigung/,
);
});
});
});