Files
alpenwerk-hr/tests/unit/build-org.test.ts
Maximilian Stubhan b87c8ad64c
Some checks failed
CI / Lint, Typen, Tests, Build (push) Failing after 5m40s
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
Remove Supabase
The database moved to a container of our own; the platform is gone.
This takes out what was left of it — and, where the leftovers were load
bearing, moves rather than deletes.

Moved, not deleted:

  supabase/migrations/  -> db/migrations/      the schema's source of truth
  supabase/build-org.ts -> scripts/build-org.ts
  lib/supabase/types.ts -> lib/types.ts        52 import sites repointed

The bookkeeping needed care. It lived in `supabase_migrations.schema_migrations`,
and simply renaming the schema would have left the runner facing an empty
table: it would have called all 67 migrations pending and replayed them
against a database that is long since current. So the runner now creates
`migrationen.schema_migrations` and, once, copies the old rows across —
guarded so a second run does nothing and a fresh database skips it entirely.
Only then does migration 20260907100000 drop the old schema.

Deleted: the CLI config, the seed, the historical schema/function dumps
(nothing read them), scripts/umzug-von-supabase.sh (the move is done), and
both Supabase packages plus the CLI. Nothing in the application imported
them — the build now succeeds with no environment variables at all, which
is the proof.

Integration tests: six of them signed in through Supabase Auth and asserted
against the anon key and the service role. That model is gone, so the tests
were not portable — they are deleted. session-context and
employee-status-filter already ran on pg and are untouched; om-reporting is
ported to a direct connection because it guards a real risk (the reporting
line rule exists twice, once in SQL and once in TypeScript).

CI: the integration job started a Supabase stack. It now runs a postgres
service, applies deploy/db-init and every migration to an empty database —
that was the valuable part, and it still holds — then checks that a second
run is a no-op, which is what proves the bookkeeping works.

Docs: security-review.md audited a service-role key, a cookie adapter and
auth.users, none of which exist. Restating findings about removed components
would suggest today's system had been reviewed; it has not. It now records
what was removed and says a fresh review is due. data-model.md was already
marked obsolete and described the pre-OM schema; azure-migration.md was a
plan for a route not taken. Both deleted.

Verified: npm ci, typecheck, lint, 445 tests, build — all clean without the
packages. Integration tests skip cleanly with no database. Migration SQL and
the runner are reviewed but NOT executed: no Docker here, and the old
instance no longer resolves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 10:43:22 +02:00

163 lines
6.2 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { buildOrg, type DivisionDef } from "@/scripts/build-org";
// Die Konstruktion des Org-Baums ist die Stelle, an der sich Elternbezüge
// und Leitungsplanstellen falsch verdrahten lassen, ohne dass es auffällt:
// ein Team unter dem falschen Bereich sieht im Organigramm plausibel aus.
const DIVISIONS: DivisionDef[] = [
{
name: "Produktion",
headTitle: "Bereichsleitung Produktion",
departments: [
{
name: "Fertigung",
leadTitle: "Abteilungsleitung Fertigung",
teams: [
{ name: "Montage", leadTitle: "Teamleitung Montage", icTitles: ["Monteur:in", "Anlagenführer:in"], baseSize: 3 },
{ name: "CNC", leadTitle: "Teamleitung CNC", icTitles: ["CNC-Fräser:in"], baseSize: 2 },
],
},
{
name: "Instandhaltung",
leadTitle: "Abteilungsleitung Instandhaltung",
teams: [{ name: "Mechanik", leadTitle: "Teamleitung Mechanik", icTitles: ["Schlosser:in"], baseSize: 2 }],
},
],
},
{
name: "IT",
headTitle: "Bereichsleitung IT",
departments: [
{
name: "Infrastruktur",
leadTitle: "Abteilungsleitung Infrastruktur",
teams: [{ name: "IT-Support", leadTitle: "Teamleitung IT-Support", icTitles: ["Systemadministrator:in"], baseSize: 2 }],
},
],
},
];
function build() {
let n = 0;
return buildOrg("Alpenwerk Industrie GmbH", DIVISIONS, () => `id-${++n}`);
}
describe("Struktur", () => {
it("hat genau eine Wurzel, und das ist die Gesellschaft", () => {
const { units } = build();
const roots = units.filter((u) => u.parent_id === null);
expect(roots).toHaveLength(1);
expect(roots[0].unit_type).toBe("Gesellschaft");
});
it("hängt jede Einheit unter den richtigen Typ", () => {
const { units } = build();
const byId = new Map(units.map((u) => [u.id, u]));
const erwartetesElternteil = { Bereich: "Gesellschaft", Abteilung: "Bereich", Team: "Abteilung" } as const;
for (const u of units) {
if (u.unit_type === "Gesellschaft") continue;
const parent = byId.get(u.parent_id!);
expect(parent?.unit_type, `${u.name} hängt falsch`).toBe(erwartetesElternteil[u.unit_type]);
}
});
it("baut die Ebenen vollständig auf", () => {
const { units } = build();
const zahl = (t: string) => units.filter((u) => u.unit_type === t).length;
expect(zahl("Gesellschaft")).toBe(1);
expect(zahl("Bereich")).toBe(2);
expect(zahl("Abteilung")).toBe(3);
expect(zahl("Team")).toBe(4);
});
it("führt jede Einheit über parent_id auf die Wurzel zurück", () => {
// Ein abgehängter Teilbaum würde im Organigramm nie gerendert.
const { units } = build();
const byId = new Map(units.map((u) => [u.id, u]));
for (const start of units) {
const gesehen = new Set<string>();
let cur = start;
while (cur.parent_id && !gesehen.has(cur.id)) {
gesehen.add(cur.id);
cur = byId.get(cur.parent_id)!;
}
expect(cur.parent_id, `${start.name} erreicht die Wurzel nicht`).toBeNull();
}
});
it("vergibt eindeutige Org-Nummern im richtigen Nummernkreis", () => {
const { units } = build();
expect(new Set(units.map((u) => u.org_number)).size).toBe(units.length);
const prefix = { Gesellschaft: "10", Bereich: "20", Abteilung: "21", Team: "22" } as const;
for (const u of units) expect(u.org_number.startsWith(prefix[u.unit_type]), u.name).toBe(true);
});
});
describe("Planstellen", () => {
it("gibt jeder Einheit genau eine Leitungsplanstelle", () => {
// Der Unique-Index in der Datenbank erzwingt das ebenfalls; hier soll
// der Seed gar nicht erst dagegenlaufen.
const { units, positions } = build();
for (const u of units) {
const chiefs = positions.filter((p) => p.org_unit_id === u.id && p.is_chief);
expect(chiefs, `${u.name}`).toHaveLength(1);
}
});
it("legt auch für die Abteilung eine Leitung an", () => {
// Die Ebene, die im Altmodell gefehlt hat.
const { units, positions } = build();
const abteilungen = units.filter((u) => u.unit_type === "Abteilung");
expect(abteilungen.length).toBeGreaterThan(0);
for (const a of abteilungen) {
expect(positions.some((p) => p.org_unit_id === a.id && p.is_chief)).toBe(true);
}
});
it("erzeugt für jedes Team so viele Mitarbeiter-Planstellen wie vorgesehen", () => {
const { units, icPositionsByTeam } = build();
const montage = units.find((u) => u.name === "Montage")!;
expect(icPositionsByTeam.get(montage.id)).toHaveLength(3);
expect(icPositionsByTeam.get(montage.id)!.every((p) => !p.is_chief)).toBe(true);
});
it("vergibt eindeutige Planstellennummern nach dem bestehenden Muster", () => {
const { positions } = build();
expect(new Set(positions.map((p) => p.position_number)).size).toBe(positions.length);
for (const p of positions) expect(p.position_number).toMatch(/^6\d{7}$/);
});
it("hängt jede Planstelle an eine existierende Einheit", () => {
const { units, positions } = build();
const ids = new Set(units.map((u) => u.id));
for (const p of positions) expect(ids.has(p.org_unit_id), p.position_number).toBe(true);
});
});
describe("Job-Katalog", () => {
it("führt jede Tätigkeit genau einmal", () => {
// "Schlosser:in" darf nicht als zwei Einträge existieren, sonst ist eine
// Auswertung nach Tätigkeit wertlos.
const { jobs } = build();
expect(new Set(jobs.map((j) => j.title)).size).toBe(jobs.length);
expect(new Set(jobs.map((j) => j.code)).size).toBe(jobs.length);
});
it("teilt denselben Job über mehrere Planstellen", () => {
const { positions, jobs } = build();
const jobById = new Map(jobs.map((j) => [j.id, j]));
const monteur = jobs.find((j) => j.title === "Monteur:in")!;
const stellen = positions.filter((p) => p.job_id === monteur.id);
expect(stellen.length).toBeGreaterThan(1);
expect(jobById.get(stellen[0].job_id)!.title).toBe("Monteur:in");
});
it("verweist jede Planstelle auf einen existierenden Job", () => {
const { positions, jobs } = build();
const ids = new Set(jobs.map((j) => j.id));
for (const p of positions) expect(ids.has(p.job_id), p.position_number).toBe(true);
});
});