package services // Live-DB integration test for ProjectionService — applies migrations, // seeds one project + one deadline + one appointment + one // timeline_kind-tagged project_event, and asserts the merge returns // three rows in the right order. Skipped when TEST_DATABASE_URL is // unset, mirroring the convention of the other live tests in this // package. import ( "context" "errors" "os" "testing" "time" "github.com/google/uuid" "github.com/jmoiron/sqlx" _ "github.com/lib/pq" "mgit.msbls.de/m/paliad/internal/db" ) func TestProjectionService_For_MergesActuals_Live(t *testing.T) { url := os.Getenv("TEST_DATABASE_URL") if url == "" { t.Skip("TEST_DATABASE_URL not set — skipping live DB test") } if err := db.ApplyMigrations(url); err != nil { t.Fatalf("apply migrations: %v", err) } pool, err := sqlx.Connect("postgres", url) if err != nil { t.Fatalf("connect: %v", err) } defer pool.Close() ctx := context.Background() userID := uuid.New() projectID := uuid.New() deadlineID := uuid.New() apptID := uuid.New() milestoneID := uuid.New() auditOnlyID := uuid.New() // timeline_kind=NULL — must NOT surface in default read cleanup := func() { pool.ExecContext(ctx, `DELETE FROM paliad.appointments WHERE id = $1`, apptID) pool.ExecContext(ctx, `DELETE FROM paliad.deadlines WHERE id = $1`, deadlineID) pool.ExecContext(ctx, `DELETE FROM paliad.project_events WHERE id IN ($1, $2)`, milestoneID, auditOnlyID) pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE project_id = $1`, projectID) pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, projectID) pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, userID) pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, userID) } cleanup() defer cleanup() if _, err := pool.ExecContext(ctx, `INSERT INTO auth.users (id, email) VALUES ($1, 'projection-test@hlc.com')`, userID); err != nil { t.Fatalf("seed auth.users: %v", err) } if _, err := pool.ExecContext(ctx, `INSERT INTO paliad.users (id, email, display_name, office, global_role, lang) VALUES ($1, 'projection-test@hlc.com', 'Projection Test', 'munich', 'global_admin', 'de')`, userID); err != nil { t.Fatalf("seed paliad.users: %v", err) } if _, err := pool.ExecContext(ctx, `INSERT INTO paliad.projects (id, type, path, title, reference, status, created_by) VALUES ($1, 'case', $1::text, 'Projection Test Project', '2026/9993', 'active', $2)`, projectID, userID); err != nil { t.Fatalf("seed paliad.projects: %v", err) } now := time.Now().UTC() deadlineDate := now.AddDate(0, 0, 7) // a week from now apptDate := now.AddDate(0, 0, 14) // two weeks from now milestoneDate := now.AddDate(0, 0, -3) // three days ago if _, err := pool.ExecContext(ctx, `INSERT INTO paliad.deadlines (id, project_id, title, due_date, source, status, created_by) VALUES ($1, $2, 'Test Deadline', $3::date, 'manual', 'pending', $4)`, deadlineID, projectID, deadlineDate.Format("2006-01-02"), userID); err != nil { t.Fatalf("seed deadline: %v", err) } if _, err := pool.ExecContext(ctx, `INSERT INTO paliad.appointments (id, project_id, title, start_at, appointment_type, created_by) VALUES ($1, $2, 'Test Appointment', $3, 'meeting', $4)`, apptID, projectID, apptDate, userID); err != nil { t.Fatalf("seed appointment: %v", err) } // Two project_events: one with timeline_kind set (must surface), one // without (must be filtered out unless include_audit_full). if _, err := pool.ExecContext(ctx, `INSERT INTO paliad.project_events (id, project_id, event_type, title, event_date, created_by, metadata, created_at, updated_at, timeline_kind) VALUES ($1, $2, 'custom_milestone', 'Test Milestone', $3, $4, '{}'::jsonb, $5, $5, 'custom_milestone')`, milestoneID, projectID, milestoneDate, userID, now); err != nil { t.Fatalf("seed milestone project_event: %v", err) } if _, err := pool.ExecContext(ctx, `INSERT INTO paliad.project_events (id, project_id, event_type, title, event_date, created_by, metadata, created_at, updated_at) VALUES ($1, $2, 'project_created', 'Audit-Only Event', $3, $4, '{}'::jsonb, $5, $5)`, auditOnlyID, projectID, milestoneDate.Add(-1*time.Hour), userID, now); err != nil { t.Fatalf("seed audit-only project_event: %v", err) } users := NewUserService(pool) projects := NewProjectService(pool, users) eventTypes := NewEventTypeService(pool, users) deadlines := NewDeadlineService(pool, projects, eventTypes) appointments := NewAppointmentService(pool, projects) rules := NewDeadlineRuleService(pool) holidays := NewHolidayService(pool) courts := NewCourtService(pool) fristen := NewFristenrechnerService(rules, holidays, courts) projection := NewProjectionService(pool, projects, deadlines, appointments, fristen, rules) t.Run("default — only timeline_kind milestones surface", func(t *testing.T) { rows, _, err := projection.For(ctx, userID, projectID, ProjectionOpts{}) if err != nil { t.Fatalf("For: %v", err) } // Filter to seed rows so unrelated rows in the live DB don't // confuse the assertions. We reference rows by provenance ID. seen := map[string]TimelineEvent{} for _, r := range rows { switch { case r.DeadlineID != nil && *r.DeadlineID == deadlineID: seen["deadline"] = r case r.AppointmentID != nil && *r.AppointmentID == apptID: seen["appointment"] = r case r.ProjectEventID != nil && *r.ProjectEventID == milestoneID: seen["milestone"] = r case r.ProjectEventID != nil && *r.ProjectEventID == auditOnlyID: t.Errorf("audit-only project_event leaked into default read") } } if len(seen) != 3 { t.Fatalf("expected 3 seed rows, saw %d: %v", len(seen), seen) } // Sort order: milestone (3 days ago) → deadline (+7d) → appointment (+14d). // Find the indices of our seeded rows in the result and check the // relative ordering. idx := func(id uuid.UUID) int { for i, r := range rows { switch { case r.DeadlineID != nil && *r.DeadlineID == id: return i case r.AppointmentID != nil && *r.AppointmentID == id: return i case r.ProjectEventID != nil && *r.ProjectEventID == id: return i } } return -1 } if !(idx(milestoneID) < idx(deadlineID) && idx(deadlineID) < idx(apptID)) { t.Errorf("wrong sort: milestone=%d deadline=%d appt=%d (want asc)", idx(milestoneID), idx(deadlineID), idx(apptID)) } // Field shape — kind, status, deep-link IDs. dl := seen["deadline"] if dl.Kind != "deadline" { t.Errorf("deadline.Kind = %q, want deadline", dl.Kind) } if dl.Status != "open" { t.Errorf("deadline.Status = %q, want open (future date)", dl.Status) } if dl.Title != "Test Deadline" { t.Errorf("deadline.Title = %q", dl.Title) } ap := seen["appointment"] if ap.Kind != "appointment" || ap.Status != "open" { t.Errorf("appointment kind/status = %q/%q", ap.Kind, ap.Status) } ms := seen["milestone"] if ms.Kind != "milestone" || ms.Status != "off_script" { t.Errorf("milestone kind/status = %q/%q (want milestone/off_script)", ms.Kind, ms.Status) } }) t.Run("IncludeAuditFull — both project_events surface", func(t *testing.T) { rows, _, err := projection.For(ctx, userID, projectID, ProjectionOpts{IncludeAuditFull: true}) if err != nil { t.Fatalf("For audit_full: %v", err) } var sawAudit bool for _, r := range rows { if r.ProjectEventID != nil && *r.ProjectEventID == auditOnlyID { sawAudit = true break } } if !sawAudit { t.Errorf("audit-only project_event should surface with IncludeAuditFull=true") } }) t.Run("RecordCustomMilestone writes a row with timeline_kind set", func(t *testing.T) { title := "Live-Test Custom Milestone" desc := "from RecordCustomMilestone test" when := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) ev, err := projection.RecordCustomMilestone(ctx, userID, projectID, title, &desc, &when, false) if err != nil { t.Fatalf("RecordCustomMilestone: %v", err) } if ev == nil || ev.ProjectEventID == nil { t.Fatalf("RecordCustomMilestone returned nil id") } // Defer cleanup so the row doesn't leak into other tests. defer pool.ExecContext(ctx, `DELETE FROM paliad.project_events WHERE id = $1`, *ev.ProjectEventID) // Verify the row landed with the expected discriminators. var ( eventType string timelineKind *string ) if err := pool.QueryRowContext(ctx, `SELECT event_type, timeline_kind FROM paliad.project_events WHERE id = $1`, *ev.ProjectEventID).Scan(&eventType, &timelineKind); err != nil { t.Fatalf("read back: %v", err) } if eventType != "custom_milestone" { t.Errorf("event_type = %q, want custom_milestone", eventType) } if timelineKind == nil || *timelineKind != "custom_milestone" { t.Errorf("timeline_kind = %v, want custom_milestone", timelineKind) } // And it must surface in the next read. rows, _, err := projection.For(ctx, userID, projectID, ProjectionOpts{}) if err != nil { t.Fatalf("For after milestone: %v", err) } var found bool for _, r := range rows { if r.ProjectEventID != nil && *r.ProjectEventID == *ev.ProjectEventID { found = true break } } if !found { t.Errorf("newly recorded milestone did not surface in For()") } }) } // TestExpandCrossProceedingSpawns covers the Phase 3 Slice 7 // (t-paliad-188) cross-proceeding spawn wiring on a live DB with // synthetic fixtures. Three scenarios: // // 1. A spawn rule in proceeding A pointing at proceeding B → expansion // emits exactly one spawned-into TimelineEvent whose RuleCode // matches B's first (lowest sequence_order) rule. // // 2. A spawn cycle (A → B → A) → ErrCyclicSpawn surfaces; no rows // emitted on the cycle branch; the recursion stops at the second // hop without infinite-looping. // // 3. Multi-spawn defensive: proceeding A with two spawn rules each // targeting DIFFERENT downstream proceedings (B + C) → two // spawned-into rows in the output, one per target. // // Skipped when TEST_DATABASE_URL is unset. func TestExpandCrossProceedingSpawns(t *testing.T) { url := os.Getenv("TEST_DATABASE_URL") if url == "" { t.Skip("TEST_DATABASE_URL not set — skipping live DB test") } if err := db.ApplyMigrations(url); err != nil { t.Fatalf("apply migrations: %v", err) } pool, err := sqlx.Connect("postgres", url) if err != nil { t.Fatalf("connect: %v", err) } defer pool.Close() ctx := context.Background() cleanup := func() { pool.ExecContext(ctx, `SELECT set_config('paliad.audit_reason', 'slice 7 test cleanup', true)`) pool.ExecContext(ctx, `DELETE FROM paliad.deadline_rules WHERE name LIKE 'SLICE7_TEST_%'`) pool.ExecContext(ctx, `DELETE FROM paliad.proceeding_types WHERE code LIKE 'SLICE7_TEST_%'`) } cleanup() defer cleanup() type ptRow struct { ID int `db:"id"` Code string `db:"code"` } var pts []ptRow if err := pool.SelectContext(ctx, &pts, ` INSERT INTO paliad.proceeding_types (code, name, name_en, category, jurisdiction, is_active) VALUES ('SLICE7_TEST_A', 'Slice7 Test A', 'Slice7 Test A', 'fristenrechner', 'UPC', true), ('SLICE7_TEST_B', 'Slice7 Test B', 'Slice7 Test B', 'fristenrechner', 'UPC', true), ('SLICE7_TEST_C', 'Slice7 Test C', 'Slice7 Test C', 'fristenrechner', 'UPC', true) RETURNING id, code`); err != nil { t.Fatalf("seed proceeding_types: %v", err) } ptByCode := make(map[string]int, len(pts)) for _, pt := range pts { ptByCode[pt.Code] = pt.ID } insertRule := func(label, code string, ptID, sequenceOrder int, isSpawn bool, spawnTargetPT *int) uuid.UUID { if _, err := pool.ExecContext(ctx, `SELECT set_config('paliad.audit_reason', $1, true)`, "slice 7 test seed: "+label); err != nil { t.Fatalf("set audit_reason: %v", err) } id := uuid.New() // Slice 9 (t-paliad-195) dropped is_mandatory / is_optional; // the seed uses the live post-Slice-9 column set. _, err := pool.ExecContext(ctx, `INSERT INTO paliad.deadline_rules (id, proceeding_type_id, name, name_en, submission_code, duration_value, duration_unit, timing, is_court_set, is_spawn, spawn_proceeding_type_id, sequence_order, is_active, priority, lifecycle_state, created_at, updated_at) VALUES ($1, $2, $3, $3, $4, 0, 'days', 'after', false, $5, $6, $7, true, 'mandatory', 'published', now(), now())`, id, ptID, label, code, isSpawn, spawnTargetPT, sequenceOrder) if err != nil { t.Fatalf("seed rule %q: %v", label, err) } return id } bRootID := insertRule("SLICE7_TEST_B_root", "b.root", ptByCode["SLICE7_TEST_B"], 0, false, nil) bPTID := ptByCode["SLICE7_TEST_B"] aSpawnID := insertRule("SLICE7_TEST_A_spawn", "a.spawn", ptByCode["SLICE7_TEST_A"], 0, true, &bPTID) rules := NewDeadlineRuleService(pool) svc := &ProjectionService{db: pool, rules: rules} aPTID := ptByCode["SLICE7_TEST_A"] aRules, err := rules.List(ctx, &aPTID) if err != nil { t.Fatalf("load A rules: %v", err) } sourceDeadlines := []UIDeadline{ {RuleID: aSpawnID.String(), DueDate: "2026-03-15", Code: "a.spawn"}, } visited := map[int]bool{aPTID: true} rows, err := svc.expandCrossProceedingSpawns(ctx, aRules, sourceDeadlines, visited, 0) if err != nil { t.Fatalf("scenario 1 expand: %v", err) } if len(rows) != 1 { t.Fatalf("scenario 1: got %d rows, want 1", len(rows)) } if rows[0].RuleCode != "b.root" { t.Errorf("scenario 1: RuleCode=%q, want b.root", rows[0].RuleCode) } if rows[0].DeadlineRuleID == nil || *rows[0].DeadlineRuleID != bRootID { t.Errorf("scenario 1: DeadlineRuleID = %v, want %v", rows[0].DeadlineRuleID, bRootID) } if rows[0].DependsOnRuleCode != "a.spawn" { t.Errorf("scenario 1: DependsOnRuleCode = %q, want a.spawn", rows[0].DependsOnRuleCode) } if rows[0].DependsOnDate == nil || rows[0].DependsOnDate.Format("2006-01-02") != "2026-03-15" { t.Errorf("scenario 1: DependsOnDate = %v, want 2026-03-15", rows[0].DependsOnDate) } if rows[0].Track != "spawn" { t.Errorf("scenario 1: Track = %q, want spawn", rows[0].Track) } // Scenario 2: cycle A → B → A. _ = insertRule("SLICE7_TEST_B_spawn_back", "b.spawn_back", ptByCode["SLICE7_TEST_B"], 1, true, &aPTID) aRules2, _ := rules.List(ctx, &aPTID) rows2, err := svc.expandCrossProceedingSpawns(ctx, aRules2, sourceDeadlines, map[int]bool{aPTID: true}, 0) if err == nil { t.Fatalf("scenario 2: expected ErrCyclicSpawn, got nil (rows=%d)", len(rows2)) } if !errors.Is(err, ErrCyclicSpawn) { t.Errorf("scenario 2: wrong error type: %v", err) } // Scenario 3: multi-spawn defensive. Drop the cycle-edge first. pool.ExecContext(ctx, `SELECT set_config('paliad.audit_reason', 'slice 7 test: drop B->A spawn for multi-spawn scenario', true)`) pool.ExecContext(ctx, `DELETE FROM paliad.deadline_rules WHERE name = 'SLICE7_TEST_B_spawn_back'`) cPTID := ptByCode["SLICE7_TEST_C"] insertRule("SLICE7_TEST_C_root", "c.root", ptByCode["SLICE7_TEST_C"], 0, false, nil) aSpawnC := insertRule("SLICE7_TEST_A_spawn_c", "a.spawn_c", ptByCode["SLICE7_TEST_A"], 1, true, &cPTID) aRules3, _ := rules.List(ctx, &aPTID) sourceDeadlines3 := []UIDeadline{ {RuleID: aSpawnID.String(), DueDate: "2026-03-15", Code: "a.spawn"}, {RuleID: aSpawnC.String(), DueDate: "2026-04-01", Code: "a.spawn_c"}, } rows3, err := svc.expandCrossProceedingSpawns(ctx, aRules3, sourceDeadlines3, map[int]bool{aPTID: true}, 0) if err != nil { t.Fatalf("scenario 3 expand: %v", err) } if len(rows3) != 2 { t.Fatalf("scenario 3: got %d rows, want 2", len(rows3)) } wantCodes := map[string]bool{"b.root": false, "c.root": false} for _, ev := range rows3 { if _, ok := wantCodes[ev.RuleCode]; ok { wantCodes[ev.RuleCode] = true } } for code, seen := range wantCodes { if !seen { t.Errorf("scenario 3: missing spawned-into row for %q", code) } } }