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; } // Pulled from the seeded dataset (supabase/seed.ts) — any active, non-lead // employee works for read/mutation tests that don't care which one. export async function pickSeededEmployee( filter: Partial<{ status: EmploymentStatus; is_lead: boolean }> = {} ): Promise<{ id: string; team_id: string | null; division_id: string; manager_id: string | null; status: string; }> { let q = adminClient.from("employees").select("id, team_id, division_id, manager_id, status").limit(1); if (filter.status) q = q.eq("status", filter.status); if (filter.is_lead !== undefined) q = q.eq("is_lead", filter.is_lead); const { data, error } = await q.maybeSingle(); if (error || !data) throw new Error(`pickSeededEmployee failed: ${error?.message ?? "no matching row"}`); return data; } export async function pickSeededTeam(excludeTeamId?: string): Promise<{ id: string }> { const q = adminClient.from("teams").select("id").limit(2); const { data, error } = await q; if (error || !data?.length) throw new Error(`pickSeededTeam failed: ${error?.message}`); const match = data.find((t) => t.id !== excludeTeamId) ?? data[0]; return match; } 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; } // The seeded org guarantees exactly one active team lead per team (§2's // "reports-to" rule) — resolve_manager_for() relies on the same query. export async function teamLeadId(teamId: string): Promise { const { data, error } = await adminClient .from("employees") .select("id") .eq("team_id", teamId) .eq("is_lead", true) .neq("status", "Ausgetreten") .maybeSingle(); if (error) throw new Error(`teamLeadId(${teamId}) failed: ${error.message}`); return data?.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); } // Hires a throwaway employee into `teamId` via the real hire_employee RPC // (not a raw insert) so every mutation test starts from a state the app // itself can produce. Caller must clean up with deleteTestEmployee. export async function hireTestEmployee( hrClient: SupabaseClient, teamId: 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, team_id: teamId, job_title: "Integrationstest-Rolle", 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); }