Files
alpenwerk-hr/tests/unit/acting-manager.test.ts
Maximilian Stubhan 2776c33d08
Some checks failed
CI / Lint, Typen, Tests, Build (push) Successful in 11m17s
CI / Integrationstests (echtes Postgres) (push) Failing after 5m18s
Roll reporting up past an absent manager, and say so on both sides
While somebody is on a long-term absence their reports report to the next
management level, and it keeps rolling up until it reaches somebody present.
Derived at read time in lib/acting-manager.ts rather than written to
employees.manager_id: the absent person stays formally in charge, so the
stand-in has to be visible as a stand-in rather than quietly replacing them.
Both ids therefore travel to the UI, and both sides carry a badge — the
absent person ("Abwesend · Vertretung: X") and anyone now reporting
elsewhere ("Vertretung für Y").

Three cases the walk has to survive, all covered by tests:
- Several absent levels in a row — it keeps climbing, and still names the
  *recorded* manager as the one being covered for, not the level skipped.
- Everyone above absent — it stops and keeps the recorded manager. Re-rooting
  a team to the top of the chart would distort more than showing an absent
  manager whose absence is labelled anyway.
- A manager_id cycle, which nothing in the schema forbids.

An absent lead needs no separate deputy field: their stand-in is simply
their own acting manager, the same one their reports moved to.
2026-07-27 12:03:34 +02:00

94 lines
4.1 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { resolveActingManager, resolveActingManagers, type ChainNode } from "@/lib/acting-manager";
// Nobody's manager_id is rewritten for an absence — the stand-in is derived
// here, at read time, so the absent person stays formally in charge and the
// arrangement can be shown as temporary.
function node(id: string, managerId: string | null, absent = false): ChainNode {
return { id, managerId, absent };
}
function chain(...nodes: ChainNode[]) {
return new Map(nodes.map((n) => [n.id, n]));
}
describe("resolveActingManager", () => {
it("leaves a present manager alone", () => {
const ic = node("ic", "lead");
const map = chain(ic, node("lead", "head"));
expect(resolveActingManager(ic, map)).toEqual({ actingManagerId: "lead", coveredForId: null });
});
it("rolls up one level when the manager is absent", () => {
const ic = node("ic", "lead");
const map = chain(ic, node("lead", "head", true), node("head", "ceo"));
expect(resolveActingManager(ic, map)).toEqual({ actingManagerId: "head", coveredForId: "lead" });
});
it("keeps rolling up while each level is absent", () => {
const ic = node("ic", "lead");
const map = chain(ic, node("lead", "head", true), node("head", "ceo", true), node("ceo", null));
// coveredForId names the *recorded* manager, which is what the employee
// and the org chart know about — not the intermediate level skipped.
expect(resolveActingManager(ic, map)).toEqual({ actingManagerId: "ceo", coveredForId: "lead" });
});
it("gives an absent lead the same stand-in as their own reports", () => {
// The deputy for an absent person is simply their own acting manager,
// which is why no separate deputy field is needed anywhere.
const lead = node("lead", "head", true);
const map = chain(node("ic", "lead"), lead, node("head", "ceo"));
expect(resolveActingManager(lead, map).actingManagerId).toBe("head");
});
it("keeps the recorded manager when everyone above is absent", () => {
// Re-rooting the team to the top of the chart would be a bigger
// distortion than showing the absent manager, whose absence is labelled
// regardless.
const ic = node("ic", "lead");
const map = chain(ic, node("lead", "ceo", true), node("ceo", null, true));
expect(resolveActingManager(ic, map)).toEqual({ actingManagerId: "lead", coveredForId: null });
});
it("returns nothing for somebody at the top", () => {
const ceo = node("ceo", null);
expect(resolveActingManager(ceo, chain(ceo))).toEqual({ actingManagerId: null, coveredForId: null });
});
it("keeps the recorded manager when they are not in the visible set", () => {
// A manager who had already left or has not started yet; the snapshot's
// own re-rooting deals with that case.
const ic = node("ic", "gone");
expect(resolveActingManager(ic, chain(ic))).toEqual({ actingManagerId: "gone", coveredForId: null });
});
it("terminates on a manager cycle instead of looping forever", () => {
// Nothing in the schema forbids A -> B -> A.
const ic = node("ic", "a");
const map = chain(ic, node("a", "b", true), node("b", "a", true));
expect(() => resolveActingManager(ic, map)).not.toThrow();
expect(resolveActingManager(ic, map)).toEqual({ actingManagerId: "a", coveredForId: null });
});
it("terminates when somebody is their own manager", () => {
const ic = node("ic", "self");
const map = chain(ic, node("self", "self", true));
expect(resolveActingManager(ic, map).actingManagerId).toBe("self");
});
});
describe("resolveActingManagers", () => {
it("moves a whole team up together", () => {
const results = resolveActingManagers([
node("ic1", "lead"),
node("ic2", "lead"),
node("lead", "head", true),
node("head", null),
]);
expect(results.get("ic1")).toEqual({ actingManagerId: "head", coveredForId: "lead" });
expect(results.get("ic2")).toEqual({ actingManagerId: "head", coveredForId: "lead" });
// The absent lead stays under their own manager rather than vanishing.
expect(results.get("lead")?.actingManagerId).toBe("head");
});
});