Files
alpenwerk-hr/tests/unit/reports.test.ts
Maximilian Stubhan f96773da0f Reports/Export builder (CSV/XLSX), plus a security fix pass
Adds the Berichte export pipeline (/api/export/{report,events,employees})
with shared CSV/XLSX writers in lib/export.ts and lib/reports-data.ts.

Security pass alongside it: sanitize .or() search terms against PostgREST
filter injection, sanitize spreadsheet cells against CSV/Excel formula
injection, stop leaking raw DB error messages to clients, harden the
service-role client with server-only, add baseline security headers, and
bump the vulnerable nested postcss via an override.
2026-07-15 20:34:27 +02:00

237 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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 { describe, expect, it } from "vitest";
import {
aggregateEvents,
aggregateReport,
deriveStatusAsOf,
groupKeyFor,
measureValue,
MEASURE_LABELS,
type OrgLookups,
type ReportEmployee,
type ReportEvent,
} from "@/lib/reports";
function emp(overrides: Partial<ReportEmployee> = {}): ReportEmployee {
return {
id: "1",
first_name: "Maria",
last_name: "Gruber",
job_title: "Maschinenbediener:in",
division_id: "div-1",
team_id: "team-1",
location_id: "loc-1",
status: "Aktiv",
employment_type: "Vollzeit",
contract_type: "unbefristet",
entry_date: "2020-01-01",
exit_date: null,
weekly_hours: 38.5,
source: "Extern",
paygrade: "B",
birth_date: "1990-01-01",
gender: "w",
...overrides,
};
}
const lookups: OrgLookups = {
divisionName: new Map([["div-1", "Produktion"]]),
departmentNameByTeam: new Map([["team-1", "Fertigung"]]),
teamName: new Map([["team-1", "Montage"]]),
locationName: new Map([["loc-1", "Wien-Hernals"]]),
};
describe("no salary measure in scope (consolidation §4/§8)", () => {
it("does not expose an avg_salary measure", () => {
expect(Object.keys(MEASURE_LABELS)).not.toContain("avg_salary");
});
});
describe("groupKeyFor", () => {
it("resolves division/department/team/location names via lookups", () => {
const e = emp();
expect(groupKeyFor(e, "division", lookups)).toBe("Produktion");
expect(groupKeyFor(e, "department", lookups)).toBe("Fertigung");
expect(groupKeyFor(e, "team", lookups)).toBe("Montage");
expect(groupKeyFor(e, "location", lookups)).toBe("Wien-Hernals");
});
it("falls back to a dash for department/team when the employee has no team", () => {
const e = emp({ team_id: null });
expect(groupKeyFor(e, "department", lookups)).toBe("");
expect(groupKeyFor(e, "team", lookups)).toBe("");
});
it("falls back to 'Unbekannt' for an unresolvable division/location id", () => {
const e = emp({ division_id: "ghost", location_id: "ghost" });
expect(groupKeyFor(e, "division", lookups)).toBe("Unbekannt");
expect(groupKeyFor(e, "location", lookups)).toBe("Unbekannt");
});
it("derives entry_year from entry_date", () => {
expect(groupKeyFor(emp({ entry_date: "2019-06-01" }), "entry_year", lookups)).toBe("2019");
});
});
describe("measureValue", () => {
it("returns 0 for an empty row set regardless of measure", () => {
expect(measureValue([], "headcount")).toBe(0);
});
it("counts headcount as row length", () => {
const rows = [emp(), emp({ id: "2" }), emp({ id: "3" })];
expect(measureValue(rows, "headcount")).toBe(3);
});
it("sums FTE as weekly_hours / 38.5", () => {
const rows = [emp({ weekly_hours: 38.5 }), emp({ weekly_hours: 19.25 })];
expect(measureValue(rows, "fte")).toBeCloseTo(1.5, 5);
});
it("computes parttime_rate as a percentage of Teilzeit employees", () => {
const rows = [
emp({ employment_type: "Vollzeit" }),
emp({ employment_type: "Teilzeit" }),
emp({ employment_type: "Teilzeit" }),
emp({ employment_type: "Teilzeit" }),
];
expect(measureValue(rows, "parttime_rate")).toBe(75);
});
it("computes female_share as a percentage", () => {
const rows = [emp({ gender: "w" }), emp({ gender: "w" }), emp({ gender: "m" })];
expect(measureValue(rows, "female_share")).toBeCloseTo(66.67, 1);
});
it("computes avg_age from birth_date", () => {
const today = new Date();
const thirtyYearsAgo = `${today.getFullYear() - 30}-01-01`;
const fortyYearsAgo = `${today.getFullYear() - 40}-01-01`;
const rows = [emp({ birth_date: thirtyYearsAgo }), emp({ birth_date: fortyYearsAgo })];
expect(measureValue(rows, "avg_age")).toBeCloseTo(35, 0);
});
it("computes avg_tenure in years using exit_date when present (deterministic)", () => {
const rows = [
emp({ entry_date: "2020-01-01", exit_date: "2022-01-01" }), // ~2 years
emp({ entry_date: "2020-01-01", exit_date: "2024-01-01" }), // ~4 years
];
expect(measureValue(rows, "avg_tenure")).toBeCloseTo(3, 0);
});
});
describe("aggregateReport", () => {
it("groups rows, sorts groups descending by value, and never includes a salary field", () => {
const employees = [
emp({ id: "1", division_id: "div-1" }),
emp({ id: "2", division_id: "div-1" }),
emp({ id: "3", division_id: "div-2" }),
];
const multiDivisionLookups: OrgLookups = {
...lookups,
divisionName: new Map([
["div-1", "Produktion"],
["div-2", "IT"],
]),
};
const rows = aggregateReport(employees, "headcount", "division", null, multiDivisionLookups);
expect(rows[0]).toMatchObject({ key: "Produktion", value: 2, count: 2 });
expect(rows[1]).toMatchObject({ key: "IT", value: 1, count: 1 });
for (const row of rows) {
for (const person of row.people) {
expect(person).not.toHaveProperty("monthly_salary_gross");
}
}
});
it("caps drilldown people list source data at the full group (UI caps display, not aggregation)", () => {
const employees = Array.from({ length: 15 }, (_, i) => emp({ id: String(i) }));
const rows = aggregateReport(employees, "headcount", "division", null, lookups);
expect(rows[0].people).toHaveLength(15);
});
it("adds a split breakdown per group when a split dimension is given", () => {
const employees = [
emp({ id: "1", employment_type: "Vollzeit" }),
emp({ id: "2", employment_type: "Teilzeit" }),
];
const rows = aggregateReport(employees, "headcount", "division", "employment_type", lookups);
expect(rows[0].split).toEqual(
expect.arrayContaining([
{ key: "Vollzeit", value: 1, count: 1 },
{ key: "Teilzeit", value: 1, count: 1 },
])
);
});
it("reconstructs headcount/avg_age/avg_tenure as of a past Stichtag, not today", () => {
// Entered 2019, exited 2021 — gone by today, but was active on 2020-06-01.
const employees = [emp({ id: "1", entry_date: "2019-01-01", exit_date: "2021-01-01", birth_date: "1990-01-01" })];
const asOf = "2020-06-01";
const rows = aggregateReport(employees, "headcount", "division", null, lookups, asOf);
expect(rows[0]).toMatchObject({ key: "Produktion", value: 1, count: 1 });
expect(measureValue(employees, "avg_age", asOf)).toBe(30);
expect(measureValue(employees, "avg_tenure", asOf)).toBeCloseTo(1.42, 1);
});
});
describe("deriveStatusAsOf", () => {
const base = { entry_date: "2020-01-01", exit_date: null as string | null, karenz_start_date: null as string | null, karenz_return_date: null as string | null };
it("is Geplant before the entry date", () => {
expect(deriveStatusAsOf({ ...base, entry_date: "2025-01-01" }, "2024-12-31")).toBe("Geplant");
});
it("is Ausgetreten on/after the exit date", () => {
expect(deriveStatusAsOf({ ...base, exit_date: "2022-06-01" }, "2022-06-01")).toBe("Ausgetreten");
expect(deriveStatusAsOf({ ...base, exit_date: "2022-06-01" }, "2022-05-31")).toBe("Aktiv");
});
it("is Karenz within the Karenz window, Aktiv once returned", () => {
const onKarenz = { ...base, karenz_start_date: "2023-01-01", karenz_return_date: "2023-07-01" };
expect(deriveStatusAsOf(onKarenz, "2023-03-01")).toBe("Karenz");
expect(deriveStatusAsOf(onKarenz, "2023-07-01")).toBe("Aktiv");
expect(deriveStatusAsOf(onKarenz, "2022-12-31")).toBe("Aktiv");
});
});
describe("aggregateEvents", () => {
const eventLookups: OrgLookups = {
...lookups,
divisionName: new Map([
["div-1", "Produktion"],
["div-2", "IT"],
]),
};
function ev(overrides: Partial<ReportEvent> = {}): ReportEvent {
return {
employee_id: "1",
first_name: "Maria",
last_name: "Gruber",
job_title: "Maschinenbediener:in",
division_id: "div-1",
team_id: "team-1",
location_id: "loc-1",
event_date: "2026-03-01",
event_type: "Eintritt",
description: "Eintritt als Maschinenbediener:in",
...overrides,
};
}
it("counts every event, unlike a Bestand headcount which only sees the current entry_date", () => {
const events = [ev({ employee_id: "1" }), ev({ employee_id: "1", event_date: "2026-05-01", event_type: "Beförderung" }), ev({ employee_id: "2", division_id: "div-2" })];
const rows = aggregateEvents(events, "event_type", null, eventLookups);
expect(rows.find((r) => r.key === "Eintritt")).toMatchObject({ value: 2, count: 2 });
expect(rows.find((r) => r.key === "Beförderung")).toMatchObject({ value: 1, count: 1 });
});
it("groups by the affected employee's org unit and preserves the event description on drill-down", () => {
const events = [ev({ division_id: "div-1" }), ev({ division_id: "div-2", employee_id: "2" })];
const rows = aggregateEvents(events, "division", null, eventLookups);
const produktion = rows.find((r) => r.key === "Produktion")!;
expect(produktion.people[0]).toMatchObject({ id: "1", title: "Eintritt als Maschinenbediener:in", entry_date: "2026-03-01" });
});
});