feat(stammdaten): Mitarbeiter, Abteilungen und Geraete
Die Stammdaten, an denen die Historie haengt. Personen werden deaktiviert, Geraete und Abteilungen archiviert — geloescht wird nichts, woran ein Lauf haengen kann. - Mitarbeiterliste mit Suche ueber Anzeigename, Mail, UPN und sAMAccountName, Filter nach Abteilung, standardmaessig nur aktive; die Suche ist ein eigenes Bauteil, weil #013 und #014 sie wieder brauchen - Personenseite mit Stammdaten, Geraeten, Vorgesetztem; was aus dem Verzeichnis kommt, ist gesperrt — der naechste Abgleich wuerde es ohnehin zurueckdrehen - Abteilungen unter Admin: Ziel-OU, Standardgruppen, Mail-Domain, dazu die Namen, die bei Personen vorkommen, aber nicht gepflegt sind - Geraete mit Suche ueber Hostname, Bezeichnung, Seriennummer und Tag-Code; CSV-Import mit einem Ergebnis je Zeile - Neues Recht mitarbeiterBearbeiten; die Seitenleiste fuehrt Personen und Geraete unter einem Eintrag, im Modus Dienstleistung anders beschriftet Der unabhaengige Review fand zwei Blocker. Der schwerere meldete Erfolg: ein nicht geschlossenes Anfuehrungszeichen verschluckte im CSV-Import alle Folgezeilen bis zum naechsten `"`, und der Import sagte „3 von 3 Zeilen angelegt". Ein Wert reicht jetzt nicht mehr ueber die Zeile hinaus — fuer Hostnamen und Seriennummern ist ein Umbruch praktisch immer ein vergessenes Anfuehrungszeichen, und so endet der Schaden an seiner Zeile. Der zweite: ungepflegte Abteilungen wurden ueber alle Kunden hinweg gruppiert. Dazu sieben weitere Befunde, alle behoben — darunter eine Besitzerauswahl, die Personen fremder Kunden anbot, und ein Techniker, der ein Geraet endgueltig loeschen konnte. 610 Tests gruen, 18 E2E-Ablaeufe gegen den gebauten Stand. Schliesst #011 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
e3d80e5982
commit
86d55917f9
@@ -0,0 +1,409 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { Betriebsmodus, Herkunft } from "@/generated/prisma/enums";
|
||||
import {
|
||||
ABGEGLICHENE_FELDER,
|
||||
MitarbeiterFehler,
|
||||
mitarbeiterAktivSetzen,
|
||||
mitarbeiterAnlegen,
|
||||
mitarbeiterBearbeiten,
|
||||
} from "@/server/services/mitarbeiter";
|
||||
import {
|
||||
abteilungsNamen,
|
||||
geraeteDesMitarbeiters,
|
||||
mitarbeiterHolen,
|
||||
mitarbeiterListe,
|
||||
} 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("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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user