Files
alpenwerk-hr/tests/unit/history.test.ts
Maximilian Stubhan 16216c5537 Let a planned absence be called off
The old refusal read: "Diese Abwesenheit ist noch nicht wirksam. Sie muss
über den Vorgang selbst abgebrochen werden." There was no such way. The
row sat in the file, the scheduled change kept running toward its date,
and nothing could stop either one.

That is not hypothetical. One person went absent in July, came back in
August, and still has a second return booked for the first of September
— recorded while they were already working again. The guard added
yesterday stops a third from being written; it does not remove the one
that exists.

Absences are called off whole, not field by field. For a planned
contract change the scheduled payload gets the affected fields lifted
out of it and runs on with the rest; an absence has no fields in that
map, and half an absence is not a thing anyone means. So the whole
scheduled change is cancelled, and what it had already noted on the
person goes with it: the date they were to be away from, the date they
were to come back on. Left behind, the profile would show an absence
with no event behind it. If the absence is still running, the return
date planned when it began applies again.

The link between the row and the scheduled change had to exist first —
start_karenz and record_karenz_return now record it. Existing rows get
it backfilled, but only where one running change of that kind falls on
that person and that day. Where two would match, the row keeps refusing:
guessing which process to cancel is worse than refusing to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 11:44:21 +02:00

185 lines
8.6 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { darfKorrigiertWerden, loeschVorschau } from "@/lib/history";
import type { AuditChange, HistoryEventType } from "@/lib/supabase/types";
const HEUTE = "2026-08-13";
function eintrag(teil: Partial<{ id: string; event_type: HistoryEventType; event_date: string; created_at: string; changes: AuditChange[] | null; pending_id: string | null }> = {}) {
return {
id: "h1",
event_type: "Stammdatenänderung" as HistoryEventType,
event_date: "2026-08-10",
created_at: "2026-08-10T09:00:00.000Z",
changes: [{ feld: "Adresse", vorher: "Feldweg 66", nachher: "Bahnhofstrasse 5" }] as AuditChange[] | null,
...teil,
};
}
describe("darfKorrigiertWerden", () => {
it("lässt eine wirksame Stammdatenänderung mit Werten zu", () => {
expect(darfKorrigiertWerden(eintrag(), HEUTE)).toEqual({ erlaubt: true });
});
it("lässt eine Vertragsänderung ebenso zu", () => {
expect(darfKorrigiertWerden(eintrag({ event_type: "Vertragsänderung" }), HEUTE).erlaubt).toBe(true);
});
it("schützt den Eintritt", () => {
const u = darfKorrigiertWerden(eintrag({ event_type: "Eintritt", changes: null }), HEUTE);
expect(u.erlaubt).toBe(false);
expect(u.erlaubt === false && u.grund).toMatch(/Anfang der Zeitleiste/);
});
it.each<HistoryEventType>(["Versetzung", "Beförderung", "Austritt", "Wiedereintritt", "Reorganisation"])(
"verweist bei %s auf den fachlichen Vorgang",
(typ) => {
const u = darfKorrigiertWerden(eintrag({ event_type: typ }), HEUTE);
expect(u.erlaubt).toBe(false);
expect(u.erlaubt === false && u.grund).toMatch(/passenden Vorgang|Planstellen oder den Status/);
}
);
it("lässt Abwesenheit und Rückkehr zu", () => {
// Eine versehentlich erfasste Abwesenheit war sonst nur durch eine zweite
// Buchung loszuwerden — und dann stünden zwei Einträge in der Akte, von
// denen der erste nie stattgefunden hat.
expect(darfKorrigiertWerden(eintrag({ event_type: "Karenz" }), HEUTE).erlaubt).toBe(true);
expect(darfKorrigiertWerden(eintrag({ event_type: "Rückkehr" }), HEUTE).erlaubt).toBe(true);
});
it("lässt Zukünftiges zu, sobald der geplante Vorgang bekannt ist", () => {
// Das ist der Sinn von pending_id: ohne Bezug liesse sich die geplante
// Änderung nur über Person und Datum finden, und das ist nicht eindeutig.
expect(darfKorrigiertWerden(eintrag({ event_date: "2026-09-01", pending_id: "p1" }), HEUTE).erlaubt).toBe(true);
});
it("weist Zukünftiges ohne Bezug zum Vorgang ab", () => {
const u = darfKorrigiertWerden(eintrag({ event_date: "2026-09-01", pending_id: null }), HEUTE);
expect(u.erlaubt).toBe(false);
expect(u.erlaubt === false && u.grund).toMatch(/kein Vorgang hinterlegt/);
});
it("zählt den heutigen Tag als wirksam", () => {
expect(darfKorrigiertWerden(eintrag({ event_date: HEUTE }), HEUTE).erlaubt).toBe(true);
});
it("weist Einträge ohne Feldwerte ab", () => {
for (const ohne of [null, []]) {
const u = darfKorrigiertWerden(eintrag({ changes: ohne }), HEUTE);
expect(u.erlaubt).toBe(false);
expect(u.erlaubt === false && u.grund).toMatch(/keine Feldwerte/);
}
});
});
describe("loeschVorschau", () => {
const alt = eintrag({
id: "alt",
event_date: "2026-08-01",
created_at: "2026-08-01T09:00:00.000Z",
changes: [
{ feld: "Adresse", vorher: "Feldweg 66", nachher: "Zwischenweg 1" },
{ feld: "Telefon", vorher: "0664 000", nachher: "0664 111" },
],
});
const neu = eintrag({
id: "neu",
event_date: "2026-08-10",
created_at: "2026-08-10T09:00:00.000Z",
changes: [{ feld: "Adresse", vorher: "Zwischenweg 1", nachher: "Endstrasse 9" }],
});
it("lässt ein Feld stehen, das später erneut geändert wurde", () => {
// Das ist die Regel, um die es geht: die letztgültige Änderung schlägt.
const v = loeschVorschau(alt, [alt, neu]);
expect(v.find((x) => x.feld === "Adresse")!.bleibt).toBe(true);
});
it("setzt ein Feld zurück, das seither niemand angefasst hat", () => {
const v = loeschVorschau(alt, [alt, neu]);
const tel = v.find((x) => x.feld === "Telefon")!;
expect(tel.bleibt).toBe(false);
expect(tel.auf).toBe("0664 000");
});
it("setzt beim neuesten Eintrag alles zurück", () => {
const v = loeschVorschau(neu, [alt, neu]);
expect(v.every((x) => !x.bleibt)).toBe(true);
expect(v[0].auf).toBe("Zwischenweg 1");
});
it("unterscheidet zwei Einträge am selben Tag über die Erfassungszeit", () => {
const frueh = eintrag({ id: "a", event_date: HEUTE, created_at: `${HEUTE}T08:00:00.000Z` });
const spaet = eintrag({ id: "b", event_date: HEUTE, created_at: `${HEUTE}T16:00:00.000Z` });
expect(loeschVorschau(frueh, [frueh, spaet])[0].bleibt).toBe(true);
expect(loeschVorschau(spaet, [frueh, spaet])[0].bleibt).toBe(false);
});
it("nennt die Richtung so, wie sie im Dialog steht", () => {
// von = was jetzt drinsteht, auf = worauf zurückgesetzt wird.
const [v] = loeschVorschau(neu, [neu]);
expect(v).toMatchObject({ feld: "Adresse", von: "Endstrasse 9", auf: "Zwischenweg 1" });
});
});
// Die Reihenfolge zählt: eine Rückkehr setzt eine Abwesenheit voraus. Bliebe
// sie stehen, während die Abwesenheit verschwindet, stünde in der Akte eine
// Rückkehr aus dem Nichts — und der Status ergäbe sich aus einem Eintrag,
// dessen Ausgangslage gelöscht ist.
describe("Reihenfolge von Abwesenheit und Rückkehr", () => {
const karenz = eintrag({ id: "k", event_type: "Karenz", event_date: "2026-07-15", created_at: "2026-07-15T09:00:00.000Z" });
const rueckkehr = eintrag({ id: "r", event_type: "Rückkehr", event_date: "2026-08-10", created_at: "2026-08-10T09:00:00.000Z" });
it("verweigert die Abwesenheit, solange die Rückkehr steht", () => {
const u = darfKorrigiertWerden(karenz, HEUTE, [karenz, rueckkehr]);
expect(u.erlaubt).toBe(false);
expect(u.erlaubt === false && u.grund).toMatch(/Rückkehr.*zuerst gelöscht/);
});
it("lässt die Rückkehr jederzeit zurücknehmen — sie ist das obere Ende", () => {
expect(darfKorrigiertWerden(rueckkehr, HEUTE, [karenz, rueckkehr]).erlaubt).toBe(true);
});
it("gibt die Abwesenheit frei, sobald die Rückkehr weg ist", () => {
expect(darfKorrigiertWerden(karenz, HEUTE, [karenz]).erlaubt).toBe(true);
});
it("stört sich nicht an einer früheren Rückkehr aus einer anderen Abwesenheit", () => {
const frueher = eintrag({ id: "r0", event_type: "Rückkehr", event_date: "2026-01-05", created_at: "2026-01-05T09:00:00.000Z" });
expect(darfKorrigiertWerden(karenz, HEUTE, [frueher, karenz]).erlaubt).toBe(true);
});
it("bricht eine noch nicht wirksame Abwesenheit ab, wenn ihr Vorgang bekannt ist", () => {
// Vorher hiess es hier „Sie muss über den Vorgang selbst abgebrochen
// werden" — nur gab es diesen Weg nirgends. Die Zeile stand in der Akte,
// der Vorgang lief weiter, und niemand konnte beides aufhalten.
const geplant = eintrag({ event_type: "Karenz", event_date: "2099-01-01", pending_id: "p1", changes: null });
expect(darfKorrigiertWerden(geplant, HEUTE, [geplant]).erlaubt).toBe(true);
});
it("bleibt bei einer geplanten Abwesenheit ohne hinterlegten Vorgang stehen", () => {
// Ohne Verweis liesse sich nur über Person und Datum raten, welcher
// Vorgang gemeint ist — und geraten wird beim Abbrechen nicht.
const geplant = eintrag({ event_type: "Karenz", event_date: "2099-01-01", pending_id: null, changes: null });
const u = darfKorrigiertWerden(geplant, HEUTE, [geplant]);
expect(u.erlaubt).toBe(false);
expect(u.erlaubt === false && u.grund).toMatch(/kein Vorgang hinterlegt/);
});
it("verlangt bei einer bereits wirksamen Abwesenheit weiterhin Feldwerte", () => {
// Rückwärts geht es nur, wenn irgendwo steht, was vorher galt.
const alt = eintrag({ event_type: "Karenz", event_date: "2026-07-15", changes: null });
const u = darfKorrigiertWerden(alt, HEUTE, [alt]);
expect(u.erlaubt).toBe(false);
expect(u.erlaubt === false && u.grund).toMatch(/keine Feldwerte/);
});
it("hält die Reihenfolge auch bei geplanten Einträgen ein", () => {
const geplanteKarenz = eintrag({ id: "k2", event_type: "Karenz", event_date: "2099-01-01", pending_id: "p1", changes: null });
const geplanteRueckkehr = eintrag({ id: "r2", event_type: "Rückkehr", event_date: "2099-06-01", pending_id: "p2", changes: null });
const u = darfKorrigiertWerden(geplanteKarenz, HEUTE, [geplanteKarenz, geplanteRueckkehr]);
expect(u.erlaubt).toBe(false);
expect(u.erlaubt === false && u.grund).toMatch(/zuerst gelöscht/);
});
});