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>
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user