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 = createClient(URL, SERVICE_ROLE_KEY, { auth: { autoRefreshToken: false, persistSession: false }, }); export function anonClient(): SupabaseClient { 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 { 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 { 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 { await adminClient.from("profiles").delete().eq("id", user.id); await adminClient.auth.admin.deleteUser(user.id); } export async function signInAs(user: TestUser): Promise> { 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 { 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, positionId: string, overrides: Partial> = {} ): Promise { 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 { 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, orgUnitId: string, overrides: Partial> = {} ): Promise { 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 { // 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); }