Remove Supabase
Some checks failed
CI / Lint, Typen, Tests, Build (push) Failing after 5m40s
CI / Migrationen auf leerer Datenbank (push) Has been cancelled

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>
This commit is contained in:
2026-09-07 10:43:22 +02:00
parent 5c310c3a58
commit b87c8ad64c
155 changed files with 386 additions and 4014 deletions

View File

@@ -3,7 +3,7 @@ import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { HistorieTab } from "@/components/employees/tabs/HistorieTab";
import { ToastProvider } from "@/components/ui/Toast";
import type { Database } from "@/lib/supabase/types";
import type { Database } from "@/lib/types";
// Der Löschknopf hängt an einer Server-Action, und die zieht über lib/db das
// Paket `server-only` nach — im Test ein Fehler beim Import. Ersetzt wird

View File

@@ -3,7 +3,7 @@ import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { TerminatePanel } from "@/components/employees/panels/TerminatePanel";
import { ToastProvider } from "@/components/ui/Toast";
import type { Database } from "@/lib/supabase/types";
import type { Database } from "@/lib/types";
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];

View File

@@ -1,7 +1,7 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { VertragTab } from "@/components/employees/tabs/VertragTab";
import type { Database } from "@/lib/supabase/types";
import type { Database } from "@/lib/types";
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];

View File

@@ -1,95 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import {
adminClient,
createBareAuthUser,
createHrUser,
deleteTestUser,
signInAs,
type TestUser,
} from "./helpers";
// HR-only access model (supabase/migrations/20260714120000_hr_only_access.sql):
// nobody except an active, explicitly-provisioned HR user may read or write
// anything beyond their own profile row.
describe("HR-only access (is_hr_user gate)", () => {
const createdUsers: TestUser[] = [];
afterEach(async () => {
while (createdUsers.length) {
const user = createdUsers.pop()!;
await deleteTestUser(user);
}
});
it("a bare authenticated user (no profile row) reads zero employees, not an error", async () => {
const user = await createBareAuthUser();
createdUsers.push(user);
const client = await signInAs(user);
const { data, error } = await client.from("employees").select("id");
expect(error).toBeNull();
expect(data).toEqual([]);
});
it("a bare authenticated user cannot insert into org reference tables", async () => {
const user = await createBareAuthUser();
createdUsers.push(user);
const client = await signInAs(user);
const { error } = await client
.from("org_units")
.insert({ org_number: "20999999", name: `Test-${user.id}`, unit_type: "Bereich" });
expect(error).not.toBeNull();
});
it("an inactive HR profile can read its own profile row", async () => {
const user = await createHrUser({ active: false });
createdUsers.push(user);
const client = await signInAs(user);
const { data, error } = await client.from("profiles").select("id, is_active").eq("id", user.id);
expect(error).toBeNull();
expect(data).toHaveLength(1);
expect(data?.[0].is_active).toBe(false);
});
it("an inactive HR profile reads zero employees and cannot call mutation RPCs", async () => {
const user = await createHrUser({ active: false });
createdUsers.push(user);
const client = await signInAs(user);
const { data: employees, error: readError } = await client.from("employees").select("id");
expect(readError).toBeNull();
expect(employees).toEqual([]);
const { error: rpcError } = await client.rpc("hire_employee", {
payload: { first_name: "X", last_name: "Y", gender: "m", birth_date: "1990-01-01", entry_date: "2020-01-01" },
});
expect(rpcError?.message).toMatch(/Nicht berechtigt/);
});
it("an active HR user reads the seeded employee roster", async () => {
const user = await createHrUser({ active: true });
createdUsers.push(user);
const client = await signInAs(user);
const { data, error } = await client.from("employees").select("id").limit(1);
expect(error).toBeNull();
expect((data ?? []).length).toBeGreaterThan(0);
});
it("apply_due_pending_changes is revoked from authenticated users, even active HR", async () => {
const user = await createHrUser({ active: true });
createdUsers.push(user);
const client = await signInAs(user);
const { error } = await client.rpc("apply_due_pending_changes");
expect(error).not.toBeNull();
});
it("apply_due_pending_changes is callable by the service-role client", async () => {
const { data, error } = await adminClient.rpc("apply_due_pending_changes");
expect(error).toBeNull();
expect(typeof data).toBe("number");
});
});

View File

@@ -1,135 +0,0 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
adminClient,
createHrUser,
createTestPosition,
deleteTestEmployee,
deleteTestPosition,
deleteTestUser,
hireTestEmployee,
isoDateOffset,
pickSeededUnit,
signInAs,
type TestUser,
} from "./helpers";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "@/lib/supabase/types";
// Two guards added by supabase/migrations/20260714120600_data_integrity_guards.sql
// that previously had no enforcement anywhere: a Karenz return date may not
// precede its own Karenz start, and a history entry may not predate the
// employee's entry date.
describe("data integrity guards", () => {
let hrUser: TestUser;
let hrClient: SupabaseClient<Database>;
let unitA: { id: string };
const employeeIds: string[] = [];
const positionIds: string[] = [];
beforeAll(async () => {
hrUser = await createHrUser({ active: true });
hrClient = await signInAs(hrUser);
unitA = await pickSeededUnit();
});
afterAll(async () => {
for (const id of employeeIds) await deleteTestEmployee(id);
for (const id of positionIds) await deleteTestPosition(id);
await deleteTestUser(hrUser);
});
// Jede Einstellung braucht im OM-Modell eine freie Zielplanstelle; eine
// geteilte wäre nach der ersten besetzt.
async function freshPosition(): Promise<string> {
const id = await createTestPosition(hrClient, unitA.id, { valid_from: isoDateOffset(-40) });
positionIds.push(id);
return id;
}
async function freshEmployeeOnKarenz(): Promise<{ employeeId: string; karenzStartDate: string }> {
const employeeId = await hireTestEmployee(hrClient, await freshPosition());
employeeIds.push(employeeId);
const karenzStartDate = isoDateOffset(-5);
const { error } = await hrClient.rpc("start_karenz", {
payload: { employee_id: employeeId, karenz_start_date: karenzStartDate, planned_return_date: isoDateOffset(100) },
});
if (error) throw new Error(`start_karenz setup failed: ${error.message}`);
return { employeeId, karenzStartDate };
}
it("adjust_karenz_return rejects a return date on or before karenz_start_date", async () => {
const { employeeId, karenzStartDate } = await freshEmployeeOnKarenz();
const { error } = await hrClient.rpc("adjust_karenz_return", {
payload: { employee_id: employeeId, new_return_date: karenzStartDate },
});
expect(error?.message).toMatch(/Rückkehrdatum muss nach dem Karenzbeginn/);
});
it("adjust_karenz_return accepts a return date after karenz_start_date", async () => {
const { employeeId } = await freshEmployeeOnKarenz();
const newReturnDate = isoDateOffset(50);
const { error } = await hrClient.rpc("adjust_karenz_return", {
payload: { employee_id: employeeId, new_return_date: newReturnDate },
});
expect(error).toBeNull();
const { data: employee } = await adminClient.from("employees").select("karenz_return_date").eq("id", employeeId).single();
expect(employee?.karenz_return_date).toBe(newReturnDate);
});
it("record_karenz_return rejects a return date on or before karenz_start_date", async () => {
const { employeeId, karenzStartDate } = await freshEmployeeOnKarenz();
const { error } = await hrClient.rpc("record_karenz_return", {
payload: { employee_id: employeeId, return_date: karenzStartDate, employment_mode: "unverändert" },
});
expect(error?.message).toMatch(/Rückkehrdatum muss nach dem Karenzbeginn/);
});
it("record_karenz_return with a valid date flips status back to Aktiv and clears karenz_start_date", async () => {
const { employeeId } = await freshEmployeeOnKarenz();
const { error } = await hrClient.rpc("record_karenz_return", {
payload: { employee_id: employeeId, return_date: isoDateOffset(0), employment_mode: "unverändert" },
});
expect(error).toBeNull();
const { data: employee } = await adminClient
.from("employees")
.select("status, karenz_start_date, karenz_return_date")
.eq("id", employeeId)
.single();
expect(employee?.status).toBe("Aktiv");
expect(employee?.karenz_start_date).toBeNull();
expect(employee?.karenz_return_date).toBeNull();
});
it("rejects an employee_history row dated before the employee's entry_date", async () => {
const employeeId = await hireTestEmployee(hrClient, await freshPosition(), { entry_date: isoDateOffset(-10) });
employeeIds.push(employeeId);
const { error } = await adminClient.from("employee_history").insert({
employee_id: employeeId,
event_date: isoDateOffset(-11),
event_type: "Vertragsänderung",
description: "Sollte vom Trigger abgelehnt werden",
});
expect(error?.message).toMatch(/darf nicht vor dem Eintrittsdatum/);
});
it("accepts an employee_history row dated exactly on the entry_date", async () => {
const entryDate = isoDateOffset(-10);
const employeeId = await hireTestEmployee(hrClient, await freshPosition(), { entry_date: entryDate });
employeeIds.push(employeeId);
const { error } = await adminClient.from("employee_history").insert({
employee_id: employeeId,
event_date: entryDate,
event_type: "Vertragsänderung",
description: "Am Eintrittsdatum selbst, sollte erlaubt sein",
});
expect(error).toBeNull();
});
});

View File

@@ -1,199 +0,0 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
adminClient,
chiefOfUnit,
createHrUser,
createTestPosition,
deleteTestEmployee,
deleteTestPosition,
deleteTestUser,
hireTestEmployee,
isoDateOffset,
pickSeededUnit,
signInAs,
type TestUser,
} from "./helpers";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "@/lib/supabase/types";
// Deferred/effective-dated changes (supabase/migrations/20260714120200_
// effective_dating_rpcs.sql): a future "wirksam ab" date must queue a
// pending_org_changes row instead of writing immediately;
// apply_due_pending_changes() applies it once due.
//
// Im OM-Modell ist eine Versetzung der Wechsel auf eine Zielplanstelle, und
// die Berichtslinie wird nicht mehr mitgeschrieben. Geprüft wird deshalb die
// laufende Besetzung und was om_reporting_lines daraus ableitet — nicht mehr
// employees.team_id/manager_id, die es nicht mehr gibt.
describe("effective-dated mutations", () => {
let hrUser: TestUser;
let hrClient: SupabaseClient<Database>;
let unitA: { id: string };
let unitB: { id: string };
const employeeIds: string[] = [];
const positionIds: string[] = [];
beforeAll(async () => {
hrUser = await createHrUser({ active: true });
hrClient = await signInAs(hrUser);
unitA = await pickSeededUnit();
unitB = await pickSeededUnit(unitA.id);
});
afterAll(async () => {
for (const id of employeeIds) await deleteTestEmployee(id);
for (const id of positionIds) await deleteTestPosition(id);
await deleteTestUser(hrUser);
});
/** Eine Wegwerf-Planstelle in `unitId`, alt genug für einen Eintritt vor 30 Tagen. */
async function freshPosition(unitId: string): Promise<string> {
const positionId = await createTestPosition(hrClient, unitId, { valid_from: isoDateOffset(-40) });
positionIds.push(positionId);
return positionId;
}
async function freshEmployee(unitId: string): Promise<string> {
const id = await hireTestEmployee(hrClient, await freshPosition(unitId));
employeeIds.push(id);
return id;
}
async function lineOf(employeeId: string) {
const { data } = await adminClient
.rpc("om_reporting_lines", { p_as_of: isoDateOffset(0) })
.eq("employee_id", employeeId)
.single();
return data as unknown as { org_unit_id: string; formal_manager_id: string | null };
}
it("transfer_employee with today's date writes immediately", async () => {
const employeeId = await freshEmployee(unitA.id);
const target = await freshPosition(unitB.id);
const { error } = await hrClient.rpc("transfer_employee", {
payload: { employee_id: employeeId, effective_date: isoDateOffset(0), target_position_id: target },
});
expect(error).toBeNull();
const { data: assignment } = await adminClient
.from("position_assignments")
.select("position_id")
.eq("employee_id", employeeId)
.is("valid_to", null)
.single();
expect(assignment?.position_id).toBe(target);
const line = await lineOf(employeeId);
expect(line.org_unit_id).toBe(unitB.id);
expect(line.formal_manager_id).toBe(await chiefOfUnit(unitB.id));
});
it("transfer_employee with a future date defers the write and applies it once due", async () => {
const employeeId = await freshEmployee(unitA.id);
const target = await freshPosition(unitB.id);
const { error } = await hrClient.rpc("transfer_employee", {
payload: { employee_id: employeeId, effective_date: isoDateOffset(30), target_position_id: target },
});
expect(error).toBeNull();
// Not written yet — this is the exact bug the migration fixes: a
// future-dated transfer must not overwrite the live record today.
expect((await lineOf(employeeId)).org_unit_id).toBe(unitA.id);
const { data: pending } = await adminClient
.from("pending_org_changes")
.select("id, status, payload")
.eq("employee_id", employeeId)
.eq("change_type", "transfer")
.single();
expect(pending?.status).toBe("pending");
expect(pending?.payload.target_position_id).toBe(target);
// Fast-forward: simulate the effective date having arrived, then run
// the same function the daily cron route calls.
await adminClient.from("pending_org_changes").update({ effective_date: isoDateOffset(0) }).eq("id", pending!.id);
const { data: appliedCount, error: applyError } = await adminClient.rpc("apply_due_pending_changes");
expect(applyError).toBeNull();
expect(appliedCount).toBeGreaterThanOrEqual(1);
const { data: assignment } = await adminClient
.from("position_assignments")
.select("position_id")
.eq("employee_id", employeeId)
.is("valid_to", null)
.single();
expect(assignment?.position_id).toBe(target);
expect((await lineOf(employeeId)).org_unit_id).toBe(unitB.id);
const { data: appliedRow } = await adminClient
.from("pending_org_changes")
.select("status, applied_at")
.eq("id", pending!.id)
.single();
expect(appliedRow?.status).toBe("applied");
expect(appliedRow?.applied_at).not.toBeNull();
});
it("promote_employee with a future date does not change job_title/paygrade until applied", async () => {
const employeeId = await freshEmployee(unitA.id);
const { error } = await hrClient.rpc("promote_employee", {
payload: { employee_id: employeeId, effective_date: isoDateOffset(14), new_title: "Senior Testperson", new_paygrade: "D" },
});
expect(error).toBeNull();
const { data: unchanged } = await adminClient.from("employees").select("job_title, paygrade").eq("id", employeeId).single();
expect(unchanged?.job_title).not.toBe("Senior Testperson");
const { data: pending } = await adminClient
.from("pending_org_changes")
.select("id")
.eq("employee_id", employeeId)
.eq("change_type", "promotion")
.single();
await adminClient.from("pending_org_changes").update({ effective_date: isoDateOffset(0) }).eq("id", pending!.id);
await adminClient.rpc("apply_due_pending_changes");
const { data: employee } = await adminClient.from("employees").select("job_title, paygrade").eq("id", employeeId).single();
expect(employee?.job_title).toBe("Senior Testperson");
expect(employee?.paygrade).toBe("D");
});
it("start_karenz with a future date sets karenz_start_date immediately but keeps status Aktiv", async () => {
const employeeId = await freshEmployee(unitA.id);
const startDate = isoDateOffset(20);
const returnDate = isoDateOffset(200);
const { error } = await hrClient.rpc("start_karenz", {
payload: { employee_id: employeeId, karenz_start_date: startDate, planned_return_date: returnDate },
});
expect(error).toBeNull();
const { data: employee } = await adminClient
.from("employees")
.select("status, karenz_start_date")
.eq("id", employeeId)
.single();
expect(employee?.status).toBe("Aktiv");
expect(employee?.karenz_start_date).toBe(startDate);
const { data: pending } = await adminClient
.from("pending_org_changes")
.select("id")
.eq("employee_id", employeeId)
.eq("change_type", "karenz_start")
.single();
await adminClient.from("pending_org_changes").update({ effective_date: isoDateOffset(0) }).eq("id", pending!.id);
await adminClient.rpc("apply_due_pending_changes");
const { data: afterApply } = await adminClient
.from("employees")
.select("status, karenz_return_date")
.eq("id", employeeId)
.single();
expect(afterApply?.status).toBe("Karenz");
expect(afterApply?.karenz_return_date).toBe(returnDate);
});
});

View File

@@ -5,7 +5,7 @@ import { derivedStatusFilter } from "@/lib/employee-status-filter";
import type { Schema } from "@/lib/db/schema";
import { todayIso } from "@/lib/format";
import { deriveStatusAsOf } from "@/lib/reports";
import type { EmploymentStatus } from "@/lib/supabase/types";
import type { EmploymentStatus } from "@/lib/types";
// lib/employee-status-filter.ts is a SQL restatement of deriveStatusAsOf():
// the employee list pages in the database and cannot derive status in JS, so

View File

@@ -1,201 +0,0 @@
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
import { randomUUID } from "node:crypto";
import type { Database, EmploymentStatus } from "@/lib/supabase/types";
const URL = process.env.NEXT_PUBLIC_SUPABASE_URL;
const ANON_KEY = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
const SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!URL || !ANON_KEY || !SERVICE_ROLE_KEY) {
throw new Error(
"Missing Supabase env vars for integration tests. Start local Supabase (`npx supabase start`) and run " +
"`npm run test:integration`, which loads .env.test.local automatically. See README.md."
);
}
export const adminClient: SupabaseClient<Database> = createClient(URL, SERVICE_ROLE_KEY, {
auth: { autoRefreshToken: false, persistSession: false },
});
export function anonClient(): SupabaseClient<Database> {
return createClient(URL!, ANON_KEY!, { auth: { autoRefreshToken: false, persistSession: false } });
}
export type TestUser = { id: string; email: string; password: string };
// Creates a real auth.users row (via the admin API) with no profiles row —
// this is exactly the "signed up but never provisioned" state §2.3
// describes: authenticated, but not HR.
export async function createBareAuthUser(): Promise<TestUser> {
const email = `test-${randomUUID()}@example.test`;
const password = `Test-${randomUUID()}!`;
const { data, error } = await adminClient.auth.admin.createUser({ email, password, email_confirm: true });
if (error || !data.user) throw new Error(`createBareAuthUser failed: ${error?.message}`);
return { id: data.user.id, email, password };
}
export async function createHrUser(opts: { active: boolean }): Promise<TestUser> {
const user = await createBareAuthUser();
const { error } = await adminClient
.from("profiles")
.insert({ id: user.id, email: user.email, full_name: "Integrationstest HR", role: "hr", is_active: opts.active });
if (error) throw new Error(`createHrUser profile insert failed: ${error.message}`);
return user;
}
export async function deleteTestUser(user: TestUser): Promise<void> {
await adminClient.from("profiles").delete().eq("id", user.id);
await adminClient.auth.admin.deleteUser(user.id);
}
export async function signInAs(user: TestUser): Promise<SupabaseClient<Database>> {
const client = anonClient();
const { error } = await client.auth.signInWithPassword({ email: user.email, password: user.password });
if (error) throw new Error(`signInAs(${user.email}) failed: ${error.message}`);
return client;
}
// Aus dem Seed gezogen. Die Einordnung steht nicht mehr auf der Person, sie
// kommt über die laufende Besetzung — deshalb liefert das hier gleich die
// Planstelle und ihre Einheit mit.
export async function pickSeededEmployee(filter: Partial<{ status: EmploymentStatus; isChief: boolean }> = {}): Promise<{
id: string;
status: string;
position_id: string;
org_unit_id: string;
is_chief: boolean;
}> {
let q = adminClient
.from("position_assignments")
.select("employee_id, om_positions!inner(id, org_unit_id, is_chief), employees!inner(id, status)")
.is("valid_to", null)
.limit(1);
if (filter.status) q = q.eq("employees.status", filter.status);
if (filter.isChief !== undefined) q = q.eq("om_positions.is_chief", filter.isChief);
const { data, error } = await q.maybeSingle();
if (error || !data) throw new Error(`pickSeededEmployee failed: ${error?.message ?? "no matching row"}`);
const row = data as unknown as {
employee_id: string;
om_positions: { id: string; org_unit_id: string; is_chief: boolean };
employees: { status: string };
};
return {
id: row.employee_id,
status: row.employees.status,
position_id: row.om_positions.id,
org_unit_id: row.om_positions.org_unit_id,
is_chief: row.om_positions.is_chief,
};
}
/** Eine Organisationseinheit vom Typ Team, nach Möglichkeit eine andere als die gegebene. */
export async function pickSeededUnit(excludeUnitId?: string): Promise<{ id: string }> {
const { data, error } = await adminClient.from("org_units").select("id").eq("unit_type", "Team").limit(2);
if (error || !data?.length) throw new Error(`pickSeededUnit failed: ${error?.message}`);
return data.find((u) => u.id !== excludeUnitId) ?? data[0];
}
/**
* Eine heute unbesetzte Planstelle. Einstellung und Versetzung setzen im
* OM-Modell eine freie Zielplanstelle voraus — ohne die gibt es nichts zu
* testen, deshalb legt der Aufrufer sonst selbst eine an.
*/
export async function pickVacantPosition(): Promise<{ id: string; org_unit_id: string } | null> {
const { data: positions } = await adminClient.from("om_positions").select("id, org_unit_id").is("valid_to", null);
const { data: taken } = await adminClient.from("position_assignments").select("position_id").is("valid_to", null);
const besetzt = new Set((taken ?? []).map((a) => a.position_id));
return (positions ?? []).find((p) => !besetzt.has(p.id)) ?? null;
}
export async function pickSeededLocation(): Promise<{ id: string }> {
const { data, error } = await adminClient.from("locations").select("id").limit(1).maybeSingle();
if (error || !data) throw new Error(`pickSeededLocation failed: ${error?.message ?? "no rows"}`);
return data;
}
/** Wer die Leitungsplanstelle einer Einheit laufend innehat, falls jemand. */
export async function chiefOfUnit(orgUnitId: string): Promise<string | null> {
const { data, error } = await adminClient
.from("om_positions")
.select("position_assignments!inner(employee_id, valid_to)")
.eq("org_unit_id", orgUnitId)
.eq("is_chief", true)
.is("valid_to", null)
.is("position_assignments.valid_to", null)
.maybeSingle();
if (error) throw new Error(`chiefOfUnit(${orgUnitId}) failed: ${error.message}`);
const row = data as unknown as { position_assignments: { employee_id: string }[] } | null;
return row?.position_assignments[0]?.employee_id ?? null;
}
// YYYY-MM-DD, offset from today — for building "wirksam ab" test payloads
// without hardcoding dates that eventually go stale.
export function isoDateOffset(days: number): string {
const d = new Date();
d.setDate(d.getDate() + days);
return d.toISOString().slice(0, 10);
}
// Stellt eine Wegwerf-Person auf `positionId` ein — über die echte
// hire_employee-RPC, nicht per Insert, damit jeder Mutationstest von einem
// Zustand ausgeht, den die Anwendung selbst herstellen kann. Aufräumen mit
// deleteTestEmployee.
export async function hireTestEmployee(
hrClient: SupabaseClient<Database>,
positionId: string,
overrides: Partial<Record<string, unknown>> = {}
): Promise<string> {
const location = await pickSeededLocation();
const payload = {
first_name: "Integrationstest",
last_name: `Person-${randomUUID().slice(0, 8)}`,
gender: "w",
birth_date: "1990-01-01",
location_id: location.id,
// Die Tätigkeit kommt aus dem Job der Planstelle; sie wird nicht
// mitgegeben, sonst könnten die beiden auseinanderlaufen.
position_id: positionId,
entry_date: isoDateOffset(-30),
source: "Extern",
...overrides,
};
const { data, error } = await hrClient.rpc("hire_employee", { payload });
if (error || !data) throw new Error(`hireTestEmployee failed: ${error?.message ?? "no id returned"}`);
return data;
}
// employees has no cascade from audit_log (target_employee_id is a plain
// FK, immutable log by design) — clear those rows first so the employee
// delete itself doesn't fail with a foreign key violation. employee_history
// and pending_org_changes do cascade on employee_id.
export async function deleteTestEmployee(employeeId: string): Promise<void> {
await adminClient.from("audit_log").delete().eq("target_employee_id", employeeId);
await adminClient.from("employees").delete().eq("id", employeeId);
}
// Legt eine Wegwerf-Planstelle in `orgUnitId` an, über die echte
// create_position-RPC. Die vorgesetzte Person wird nicht mehr angegeben —
// sie ergibt sich aus der Einheit. Aufräumen mit deleteTestPosition, und
// zwar *vor* den Personen, die darauf sassen.
export async function createTestPosition(
hrClient: SupabaseClient<Database>,
orgUnitId: string,
overrides: Partial<Record<string, unknown>> = {}
): Promise<string> {
const payload = {
org_unit_id: orgUnitId,
job_title: `Integrationstest-Tätigkeit-${randomUUID().slice(0, 8)}`,
is_chief: false,
...overrides,
};
const { data, error } = await hrClient.rpc("create_position", { payload });
if (error || !data) throw new Error(`createTestPosition failed: ${error?.message ?? "no id returned"}`);
return data;
}
export async function deleteTestPosition(positionId: string): Promise<void> {
// Besetzungen hängen mit on delete cascade daran, der Job bleibt im
// Katalog — er ist geteilt und gehört keiner einzelnen Planstelle.
await adminClient.from("om_positions").delete().eq("id", positionId);
}

View File

@@ -1,37 +1,64 @@
import { describe, expect, it } from "vitest";
import { Pool } from "pg";
import { afterAll, describe, expect, it } from "vitest";
import { todayIso } from "@/lib/format";
import { resolveReportingLines, type OmHolder, type OmUnit } from "@/lib/om-reporting";
import { adminClient } from "./helpers";
// Die Berichtslinien-Regel existiert zweimal: als om_reporting_lines() in
// der Datenbank und als resolveReportingLines() im Anwendungscode. Zwei
// Fassungen derselben Regel driften auseinander, und die Abweichung fällt
// niemandem auf — im Organigramm stünde einfach eine andere Führungskraft
// als im Export. Also über den gesamten Bestand gegeneinanderhalten.
describe("om_reporting_lines stimmt mit resolveReportingLines überein", () => {
//
// Eigene Verbindung statt der Zugriffsschicht: geprüft wird die Regel, nicht
// die Berechtigung. Mit dem Zeilenschutz dazwischen liefe der Vergleich über
// eine gefilterte Teilmenge und bewiese nichts.
const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 2 });
describe.skipIf(!process.env.DATABASE_URL)("om_reporting_lines stimmt mit resolveReportingLines überein", () => {
const asOf = todayIso();
async function fromDatabase() {
const { data, error } = await adminClient.rpc("om_reporting_lines", { p_as_of: asOf });
if (error) throw new Error(error.message);
return data ?? [];
afterAll(async () => {
await pool.end();
});
type DbZeile = { employee_id: string; formal_manager_id: string | null; acting_manager_id: string | null };
async function fromDatabase(): Promise<DbZeile[]> {
const { rows } = await pool.query<DbZeile>("select * from om_reporting_lines($1)", [asOf]);
return rows;
}
async function fromTypeScript() {
const [{ data: units }, { data: assignments }] = await Promise.all([
adminClient.from("org_units").select("id, parent_id"),
adminClient
.from("position_assignments")
.select("employee_id, position_id, valid_from, valid_to, om_positions(org_unit_id, is_chief, valid_from, valid_to)")
.lte("valid_from", asOf)
.or(`valid_to.is.null,valid_to.gt.${asOf}`),
]);
const { rows: units } = await pool.query<{ id: string; parent_id: string | null }>(
"select id, parent_id from org_units"
);
const { data: employees } = await adminClient
.from("employees")
.select("id, karenz_start_date, karenz_return_date");
const absentById = new Map(
(employees ?? []).map((e) => [
// Dieselbe Gültigkeitsprüfung wie in der Datenbankfunktion: halboffen,
// das Ende ausgeschlossen. Sie steht hier ausgeschrieben und nicht als
// Aufruf einer gemeinsamen Hilfsfunktion — sonst prüfte der Test die
// Regel gegen sich selbst.
const { rows: besetzungen } = await pool.query<{
employee_id: string;
position_id: string;
org_unit_id: string;
is_chief: boolean;
}>(
`select a.employee_id, a.position_id, p.org_unit_id, p.is_chief
from position_assignments a
join om_positions p on p.id = a.position_id
where a.valid_from <= $1 and (a.valid_to is null or a.valid_to > $1)
and p.valid_from <= $1 and (p.valid_to is null or p.valid_to > $1)`,
[asOf]
);
const { rows: personen } = await pool.query<{
id: string;
karenz_start_date: string | null;
karenz_return_date: string | null;
}>("select id, karenz_start_date, karenz_return_date from employees");
const abwesend = new Map(
personen.map((e) => [
e.id,
Boolean(
e.karenz_start_date && e.karenz_start_date <= asOf && (!e.karenz_return_date || asOf < e.karenz_return_date)
@@ -39,22 +66,14 @@ describe("om_reporting_lines stimmt mit resolveReportingLines überein", () => {
])
);
const omUnits: OmUnit[] = (units ?? []).map((u) => ({ id: u.id, parentId: u.parent_id }));
const holders: OmHolder[] = (assignments ?? [])
.filter((a) => {
const p = a.om_positions as unknown as { valid_from: string; valid_to: string | null } | null;
return p && p.valid_from <= asOf && (p.valid_to === null || p.valid_to > asOf);
})
.map((a) => {
const p = a.om_positions as unknown as { org_unit_id: string; is_chief: boolean };
return {
employeeId: a.employee_id,
positionId: a.position_id,
orgUnitId: p.org_unit_id,
isChief: p.is_chief,
absent: absentById.get(a.employee_id) ?? false,
};
});
const omUnits: OmUnit[] = units.map((u) => ({ id: u.id, parentId: u.parent_id }));
const holders: OmHolder[] = besetzungen.map((a) => ({
employeeId: a.employee_id,
positionId: a.position_id,
orgUnitId: a.org_unit_id,
isChief: a.is_chief,
absent: abwesend.get(a.employee_id) ?? false,
}));
return resolveReportingLines(omUnits, holders);
}
@@ -83,8 +102,7 @@ describe("om_reporting_lines stimmt mit resolveReportingLines überein", () => {
it("gibt genau einer Person keine Führungskraft — der obersten Leitung", async () => {
const db = await fromDatabase();
const wurzel = db.filter((r) => r.acting_manager_id === null);
expect(wurzel).toHaveLength(1);
expect(db.filter((r) => r.acting_manager_id === null)).toHaveLength(1);
});
it("erzeugt keine Berichtslinie auf sich selbst", async () => {

View File

@@ -1,216 +0,0 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { Database } from "@/lib/supabase/types";
import {
adminClient,
createHrUser,
createTestPosition,
deleteTestEmployee,
deleteTestPosition,
deleteTestUser,
hireTestEmployee,
isoDateOffset,
pickSeededUnit,
signInAs,
type TestUser,
} from "./helpers";
// Die Besetzungshistorie (A008). Im Altmodell wurde sie von einem Trigger in
// eine eigene Tabelle mitgeschrieben; jetzt *ist* position_assignments die
// Historie — dieselben Zeilen, aus denen auch der heutige Stand kommt. Damit
// gibt es nichts mehr, was auseinanderlaufen könnte, aber die Invarianten
// müssen umso mehr halten: keine Lücke, keine Überschneidung, höchstens eine
// laufende Besetzung.
//
// Die Tests treiben die echten RPCs, nicht Inserts.
describe("position_assignments als Besetzungshistorie", () => {
let hrUser: TestUser;
let hrClient: SupabaseClient<Database>;
let unitA: { id: string };
let unitB: { id: string };
const employeeIds: string[] = [];
const positionIds: string[] = [];
beforeAll(async () => {
hrUser = await createHrUser({ active: true });
hrClient = await signInAs(hrUser);
unitA = await pickSeededUnit();
unitB = await pickSeededUnit(unitA.id);
});
afterAll(async () => {
// Planstellen vor den Personen: die Besetzungen hängen an beiden.
for (const id of positionIds) await deleteTestPosition(id);
for (const id of employeeIds) await deleteTestEmployee(id);
await deleteTestUser(hrUser);
});
async function freshPosition(orgUnitId: string): Promise<string> {
const id = await createTestPosition(hrClient, orgUnitId);
positionIds.push(id);
return id;
}
async function freshEmployee(positionId: string): Promise<string> {
const id = await hireTestEmployee(hrClient, positionId);
employeeIds.push(id);
return id;
}
async function assignmentsFor(employeeId: string) {
const { data } = await adminClient
.from("position_assignments")
.select("position_id, valid_from, valid_to")
.eq("employee_id", employeeId)
.order("valid_from");
return data ?? [];
}
it("öffnet eine Besetzung bei der Einstellung", async () => {
const positionId = await freshPosition(unitA.id);
const employeeId = await freshEmployee(positionId);
const rows = await assignmentsFor(employeeId);
expect(rows).toHaveLength(1);
expect(rows[0].position_id).toBe(positionId);
expect(rows[0].valid_to).toBeNull();
});
it("übernimmt die Tätigkeit aus dem Job der Planstelle", async () => {
// Sie wird bei der Einstellung nicht mitgegeben — sonst könnten die
// Tätigkeit auf der Person und die der Planstelle auseinanderlaufen.
const positionId = await freshPosition(unitA.id);
const employeeId = await freshEmployee(positionId);
const { data: position } = await adminClient
.from("om_positions")
.select("jobs!inner(title)")
.eq("id", positionId)
.single();
const { data: employee } = await adminClient.from("employees").select("job_title").eq("id", employeeId).single();
expect(employee?.job_title).toBe((position as unknown as { jobs: { title: string } }).jobs.title);
});
it("weist eine Einstellung auf eine bereits besetzte Planstelle zurück", async () => {
// Der Unique-Index fängt das ebenfalls ab; die RPC soll es vorher mit
// einer Meldung tun, die in der Oberfläche etwas erklärt.
const positionId = await freshPosition(unitA.id);
await freshEmployee(positionId);
await expect(hireTestEmployee(hrClient, positionId)).rejects.toThrow(/bereits besetzt/i);
});
it("schliesst die alte Besetzung und öffnet die neue bei einer Versetzung", async () => {
const from = await freshPosition(unitA.id);
const to = await freshPosition(unitB.id);
const employeeId = await freshEmployee(from);
const { error } = await hrClient.rpc("transfer_employee", {
payload: { employee_id: employeeId, effective_date: isoDateOffset(0), target_position_id: to },
});
expect(error).toBeNull();
const rows = await assignmentsFor(employeeId);
expect(rows).toHaveLength(2);
expect(rows[0].position_id).toBe(from);
expect(rows[0].valid_to).toBe(isoDateOffset(0));
expect(rows[1].position_id).toBe(to);
expect(rows[1].valid_to).toBeNull();
// Die Intervalle müssen exakt aneinanderstossen, sonst landet eine
// Stichtagsabfrage in einer Lücke.
expect(rows[1].valid_from).toBe(rows[0].valid_to);
});
it("hält höchstens eine laufende Besetzung je Person", async () => {
const from = await freshPosition(unitA.id);
const to = await freshPosition(unitB.id);
const employeeId = await freshEmployee(from);
await hrClient.rpc("transfer_employee", {
payload: { employee_id: employeeId, effective_date: isoDateOffset(0), target_position_id: to },
});
const rows = await assignmentsFor(employeeId);
expect(rows.filter((r) => r.valid_to === null)).toHaveLength(1);
});
it("weist eine Versetzung auf eine besetzte Zielplanstelle zurück", async () => {
const besetzt = await freshPosition(unitA.id);
await freshEmployee(besetzt);
const andere = await freshPosition(unitB.id);
const employeeId = await freshEmployee(andere);
const { error } = await hrClient.rpc("transfer_employee", {
payload: { employee_id: employeeId, effective_date: isoDateOffset(0), target_position_id: besetzt },
});
expect(error?.message).toMatch(/bereits besetzt/i);
});
it("merkt eine Versetzung in der Zukunft vor, statt sie sofort zu schreiben", async () => {
const from = await freshPosition(unitA.id);
const to = await freshPosition(unitB.id);
const employeeId = await freshEmployee(from);
await hrClient.rpc("transfer_employee", {
payload: { employee_id: employeeId, effective_date: isoDateOffset(30), target_position_id: to },
});
const rows = await assignmentsFor(employeeId);
expect(rows).toHaveLength(1);
expect(rows[0].position_id).toBe(from);
const { data: pending } = await adminClient
.from("pending_org_changes")
.select("change_type, effective_date, payload, status")
.eq("employee_id", employeeId);
expect(pending).toHaveLength(1);
expect(pending?.[0].status).toBe("pending");
expect((pending?.[0].payload as { target_position_id: string }).target_position_id).toBe(to);
});
it("gibt die Planstelle beim Austritt frei", async () => {
const positionId = await freshPosition(unitA.id);
const employeeId = await freshEmployee(positionId);
const { error } = await hrClient.rpc("terminate_employee", {
payload: { employee_id: employeeId, exit_date: isoDateOffset(0), exit_reason: "Kündigung AN" },
});
expect(error).toBeNull();
const rows = await assignmentsFor(employeeId);
expect(rows.filter((r) => r.valid_to === null)).toHaveLength(0);
expect(rows.at(-1)?.valid_to).toBe(isoDateOffset(0));
});
it("hinterlässt keine überschneidenden Besetzungen auf einer Planstelle", async () => {
// Der Unique-Index deckt nur die *laufende* Besetzung ab; die Historie
// könnte sich unbemerkt überschneiden.
const positionId = await freshPosition(unitA.id);
const ersteR = await freshEmployee(positionId);
await hrClient.rpc("terminate_employee", {
payload: { employee_id: ersteR, exit_date: isoDateOffset(0), exit_reason: "Kündigung AN" },
});
await freshEmployee(positionId, );
const { data } = await adminClient
.from("position_assignments")
.select("valid_from, valid_to")
.eq("position_id", positionId)
.order("valid_from");
const rows = data ?? [];
for (let i = 1; i < rows.length; i++) {
const vorher = rows[i - 1];
expect(vorher.valid_to === null || vorher.valid_to <= rows[i].valid_from, `Besetzung ${i} überschneidet`).toBe(true);
}
});
it("ist ohne aktive HR-Sitzung nicht lesbar", async () => {
const outsider = await createHrUser({ active: false });
const outsiderClient = await signInAs(outsider);
const { data } = await outsiderClient.from("position_assignments").select("id").limit(1);
expect(data ?? []).toHaveLength(0);
await deleteTestUser(outsider);
});
});

View File

@@ -1,209 +0,0 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
adminClient,
createHrUser,
createTestPosition,
deleteTestEmployee,
deleteTestPosition,
deleteTestUser,
hireTestEmployee,
isoDateOffset,
pickSeededUnit,
signInAs,
type TestUser,
} from "./helpers";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "@/lib/supabase/types";
// Planstellenpflege im OM-Modell
// (supabase/migrations/20260727130000_om_cleanup_and_positions.sql).
//
// Eine Planstelle gehört zu einer Organisationseinheit, trägt eine Tätigkeit
// aus dem Job-Katalog und ist entweder Leitung oder nicht. Was früher an der
// Ausschreibung hing — vorgesetzte Person, Team, is_lead — ergibt sich jetzt
// aus der Einheit und wird deshalb hier nicht mehr geprüft: es kann gar nicht
// mehr abweichen.
describe("Planstellen anlegen und schliessen", () => {
let hrUser: TestUser;
let hrClient: SupabaseClient<Database>;
let unit: { id: string };
const positionIds: string[] = [];
const employeeIds: string[] = [];
beforeAll(async () => {
hrUser = await createHrUser({ active: true });
hrClient = await signInAs(hrUser);
unit = await pickSeededUnit();
});
afterAll(async () => {
// Personen zuerst: die Besetzung hängt mit on delete cascade an der
// Planstelle, die Person selbst nicht.
for (const id of employeeIds) await deleteTestEmployee(id);
for (const id of positionIds) await deleteTestPosition(id);
await deleteTestUser(hrUser);
});
it("übernimmt das angegebene Gültig-ab", async () => {
const validFrom = isoDateOffset(10);
const positionId = await createTestPosition(hrClient, unit.id, { valid_from: validFrom });
positionIds.push(positionId);
const { data } = await adminClient.from("om_positions").select("valid_from").eq("id", positionId).single();
expect(data?.valid_from).toBe(validFrom);
});
it("setzt Gültig-ab ohne Angabe auf heute", async () => {
const positionId = await createTestPosition(hrClient, unit.id);
positionIds.push(positionId);
const { data } = await adminClient.from("om_positions").select("valid_from").eq("id", positionId).single();
expect(data?.valid_from).toBe(isoDateOffset(0));
});
it("hängt die Planstelle an die angegebene Einheit", async () => {
const positionId = await createTestPosition(hrClient, unit.id);
positionIds.push(positionId);
const { data } = await adminClient.from("om_positions").select("org_unit_id, is_chief").eq("id", positionId).single();
expect(data?.org_unit_id).toBe(unit.id);
expect(data?.is_chief).toBe(false);
});
it("teilt sich denselben Job-Katalogeintrag, statt ihn zu verdoppeln", async () => {
// Sonst stünden „Schlosser:in" und „Schlosser" nebeneinander und jede
// Auswertung nach Tätigkeit wäre wertlos.
const title = `Geteilte Tätigkeit ${Date.now()}`;
const first = await createTestPosition(hrClient, unit.id, { job_title: title });
const second = await createTestPosition(hrClient, unit.id, { job_title: title });
positionIds.push(first, second);
const { data } = await adminClient.from("om_positions").select("job_id").in("id", [first, second]);
expect(new Set((data ?? []).map((p) => p.job_id)).size).toBe(1);
});
it("weist eine Planstelle ohne Tätigkeit zurück", async () => {
const { error } = await hrClient.rpc("create_position", {
payload: { org_unit_id: unit.id, job_title: " " },
});
expect(error?.message).toMatch(/Tätigkeit/);
});
it("lässt keine zweite Leitungsplanstelle für dieselbe Einheit zu", async () => {
// Der Unique-Index erzwingt das ohnehin; die RPC soll es mit einer
// Meldung abfangen, die in der Oberfläche etwas erklärt.
const { data: existing } = await adminClient
.from("om_positions")
.select("org_unit_id")
.eq("is_chief", true)
.is("valid_to", null)
.limit(1)
.single();
const { error } = await hrClient.rpc("create_position", {
payload: { org_unit_id: existing!.org_unit_id, job_title: "Zweite Leitung", is_chief: true },
});
expect(error?.message).toMatch(/Leitungsplanstelle/);
});
it("löscht eine nie besetzte Planstelle vollständig", async () => {
const positionId = await createTestPosition(hrClient, unit.id);
const { error } = await hrClient.rpc("delete_position", { payload: { position_id: positionId } });
expect(error).toBeNull();
const { data } = await adminClient.from("om_positions").select("id").eq("id", positionId).maybeSingle();
expect(data).toBeNull();
});
it("weigert sich, eine besetzte Planstelle zu entfernen", async () => {
const positionId = await createTestPosition(hrClient, unit.id);
positionIds.push(positionId);
employeeIds.push(await hireTestEmployee(hrClient, positionId));
const { error } = await hrClient.rpc("delete_position", { payload: { position_id: positionId } });
expect(error?.message).toMatch(/besetzt/);
});
it("schliesst eine früher besetzte Planstelle, statt die Historie zu löschen", async () => {
// Sonst verschwände mit der Planstelle die Besetzungshistorie, und in der
// Personalakte klaffte eine Lücke.
const positionId = await createTestPosition(hrClient, unit.id, { valid_from: isoDateOffset(-40) });
positionIds.push(positionId);
const employeeId = await hireTestEmployee(hrClient, positionId);
employeeIds.push(employeeId);
await hrClient.rpc("terminate_employee", {
payload: { employee_id: employeeId, exit_date: isoDateOffset(-1), exit_reason: "Integrationstest" },
});
const { error } = await hrClient.rpc("delete_position", { payload: { position_id: positionId } });
expect(error).toBeNull();
const { data } = await adminClient.from("om_positions").select("valid_to").eq("id", positionId).maybeSingle();
expect(data?.valid_to).toBe(isoDateOffset(0));
const { data: history } = await adminClient.from("position_assignments").select("id").eq("position_id", positionId);
expect(history?.length).toBeGreaterThan(0);
});
});
describe("Besetzung", () => {
let hrUser: TestUser;
let hrClient: SupabaseClient<Database>;
let unit: { id: string };
const positionIds: string[] = [];
const employeeIds: string[] = [];
beforeAll(async () => {
hrUser = await createHrUser({ active: true });
hrClient = await signInAs(hrUser);
unit = await pickSeededUnit();
});
afterAll(async () => {
for (const id of employeeIds) await deleteTestEmployee(id);
for (const id of positionIds) await deleteTestPosition(id);
await deleteTestUser(hrUser);
});
it("lässt eine Planstelle nicht zweimal laufend besetzen", async () => {
const positionId = await createTestPosition(hrClient, unit.id, { valid_from: isoDateOffset(-40) });
positionIds.push(positionId);
employeeIds.push(await hireTestEmployee(hrClient, positionId));
await expect(hireTestEmployee(hrClient, positionId)).rejects.toThrow(/bereits besetzt/);
});
it("übernimmt die Tätigkeit aus dem Job der Planstelle", async () => {
// Der Titel wird bei der Einstellung nicht mitgegeben; sonst könnten
// Planstelle und Person unterschiedliche Tätigkeiten führen.
const title = `Tätigkeit aus dem Katalog ${Date.now()}`;
const positionId = await createTestPosition(hrClient, unit.id, { job_title: title, valid_from: isoDateOffset(-40) });
positionIds.push(positionId);
const employeeId = await hireTestEmployee(hrClient, positionId);
employeeIds.push(employeeId);
const { data } = await adminClient.from("employees").select("job_title").eq("id", employeeId).single();
expect(data?.job_title).toBe(title);
});
it("beendet die Besetzung beim Austritt und macht die Planstelle frei", async () => {
const positionId = await createTestPosition(hrClient, unit.id, { valid_from: isoDateOffset(-40) });
positionIds.push(positionId);
const employeeId = await hireTestEmployee(hrClient, positionId);
employeeIds.push(employeeId);
await hrClient.rpc("terminate_employee", {
payload: { employee_id: employeeId, exit_date: isoDateOffset(-1), exit_reason: "Integrationstest" },
});
const { data } = await adminClient
.from("position_assignments")
.select("valid_to")
.eq("position_id", positionId)
.eq("employee_id", employeeId)
.single();
expect(data?.valid_to).toBe(isoDateOffset(-1));
});
});

View File

@@ -1,140 +0,0 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { Database } from "@/lib/supabase/types";
import {
adminClient,
createHrUser,
createTestPosition,
deleteTestEmployee,
deleteTestPosition,
deleteTestUser,
hireTestEmployee,
isoDateOffset,
pickSeededUnit,
signInAs,
type TestUser,
} from "./helpers";
// SVNR validation (supabase/migrations/20260725120000_svnr_validation.sql).
// Enforced by a trigger, so these drive it through the real write paths and
// through a direct update — both must be covered by the same rule.
describe("SVNR validation", () => {
let hrUser: TestUser;
let hrClient: SupabaseClient<Database>;
let unit: { id: string };
const employeeIds: string[] = [];
const positionIds: string[] = [];
// 3·1 + 7·2 + 9·3 = 44; 010180 contributes 18; 62 mod 11 = 7
const VALID = "1237 010180";
const BIRTH_DATE = "1980-01-01";
async function locationIn(country: string): Promise<string> {
const { data } = await adminClient.from("locations").select("id").eq("country", country).limit(1).maybeSingle();
if (!data) throw new Error(`no seeded location in ${country}`);
return data.id;
}
beforeAll(async () => {
hrUser = await createHrUser({ active: true });
hrClient = await signInAs(hrUser);
unit = await pickSeededUnit();
});
afterAll(async () => {
for (const id of employeeIds) await deleteTestEmployee(id);
for (const id of positionIds) await deleteTestPosition(id);
await deleteTestUser(hrUser);
});
async function hireAt(country: string, overrides: Record<string, unknown> = {}): Promise<string> {
// Eine eigene Planstelle je Einstellung: eine geteilte wäre nach der
// ersten besetzt.
const positionId = await createTestPosition(hrClient, unit.id, { valid_from: isoDateOffset(-40) });
positionIds.push(positionId);
const id = await hireTestEmployee(hrClient, positionId, {
location_id: await locationIn(country),
birth_date: BIRTH_DATE,
...overrides,
});
employeeIds.push(id);
return id;
}
describe("is_valid_svnr", () => {
async function check(svnr: string, birthDate: string | null = null): Promise<boolean> {
const { data, error } = await adminClient.rpc("is_valid_svnr", { p_svnr: svnr, p_birth_date: birthDate });
if (error) throw new Error(error.message);
return data as unknown as boolean;
}
it("matches the TypeScript implementation on the documented cases", async () => {
expect(await check(VALID)).toBe(true);
expect(await check("1237010180")).toBe(true);
expect(await check("1234 010180")).toBe(false); // wrong check digit
expect(await check("0007 010180")).toBe(false); // 000 serial
expect(await check("0040 010180")).toBe(false); // check digit would be 10
expect(await check("1237 011380")).toBe(false); // month 13
expect(await check("123701018")).toBe(false); // nine digits
});
it("cross-checks the birth date when one is given", async () => {
expect(await check(VALID, BIRTH_DATE)).toBe(true);
expect(await check(VALID, "1980-01-02")).toBe(false);
});
});
describe("at an Austrian location", () => {
it("accepts a hire carrying a valid number", async () => {
const id = await hireAt("Österreich", { sv_nummer: VALID });
const { data } = await adminClient.from("employees").select("sv_nummer").eq("id", id).single();
expect(data?.sv_nummer).toBe(VALID);
});
it("rejects a hire carrying an invalid number", async () => {
await expect(hireAt("Österreich", { sv_nummer: "1234 010180" })).rejects.toThrow(/SV-Nummer/i);
});
it("rejects a number whose birth date disagrees with the employee record", async () => {
await expect(hireAt("Österreich", { sv_nummer: VALID, birth_date: "1975-06-30" })).rejects.toThrow(/SV-Nummer/i);
});
it("allows the field to stay empty", async () => {
const id = await hireAt("Österreich");
const { data } = await adminClient.from("employees").select("sv_nummer").eq("id", id).single();
expect(data?.sv_nummer ?? null).toBeNull();
});
it("rejects an update to an invalid number", async () => {
const id = await hireAt("Österreich", { sv_nummer: VALID });
const { error } = await adminClient.from("employees").update({ sv_nummer: "9999 010180" }).eq("id", id);
expect(error?.message ?? "").toMatch(/SV-Nummer/i);
});
});
describe("outside Austria", () => {
it("leaves the field free-form", async () => {
// The German equivalent has a different format entirely; validating it
// against the Austrian standard would reject correct data.
const id = await hireAt("Deutschland", { sv_nummer: "12 345678 A 901" });
const { data } = await adminClient.from("employees").select("sv_nummer").eq("id", id).single();
expect(data?.sv_nummer).toBe("12 345678 A 901");
});
});
describe("legacy rows", () => {
it("does not block an unrelated edit when the stored number is invalid", async () => {
// Rows predating the migration hold unvalidated values. A transfer or
// a name change must not fail because of a number nobody is touching.
const id = await hireAt("Deutschland", { sv_nummer: "0000 000000" });
const { error: moveError } = await adminClient
.from("employees")
.update({ location_id: await locationIn("Österreich") })
.eq("id", id);
expect(moveError).toBeNull();
const { error: nameError } = await adminClient.from("employees").update({ phone: "+43 1 9999999" }).eq("id", id);
expect(nameError).toBeNull();
});
});
});

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { buildOrg, type DivisionDef } from "@/supabase/build-org";
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:

View File

@@ -3,7 +3,7 @@ import { beforeAll, describe, expect, it, vi } from "vitest";
// Warum es diesen Test gibt.
//
// lib/supabase/types.ts sagt für 16 Spalten `string` und für zwei `number`.
// lib/types.ts sagt für 16 Spalten `string` und für zwei `number`.
// Der `pg`-Treiber liefert von Haus aus das Gegenteil: aus `date` wird ein
// Date-Objekt, aus `numeric` eine Zeichenkette.
//

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { darfKorrigiertWerden, loeschVorschau } from "@/lib/history";
import type { AuditChange, HistoryEventType } from "@/lib/supabase/types";
import type { AuditChange, HistoryEventType } from "@/lib/types";
const HEUTE = "2026-08-13";