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; 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; 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)); }); });