Workstream B Go sweep — matches mig 098. Every place the deadline-rules service reads/writes the per-rule identifier now uses the new column name and the new struct field. Distinct from rule_code (legal citation) and from proceeding_types.code (the proceeding's 3-segment code). Touch points: - models.DeadlineRule.Code → SubmissionCode (db + json tags renamed in lockstep — JSON contract `submission_code` is the new shape). - deadline_rule_service: ruleColumns SELECT list updated. - rule_editor_service: CreateRuleInput.Code → SubmissionCode (json tag too), INSERT + CloneAsDraft SELECT updated. - projection_service: lookupRuleByCode → lookupRuleBySubmissionCode (SQL WHERE clause + error message); every r.Code / parent.Code / rule.Code / first.Code / src.rule.Code read renamed. - fristenrechner: r.Code / prev.Code / rule.Code reads renamed in Calculate (parent-anchor + override-key + computed-by-code map) and in CalculateRule's LocalCode emission; the proceeding-code+submission- code resolver query uses `submission_code = $2`. - event_trigger_service / deadline_calculator: r.Code reads renamed. UIDeadline.Code (the calculator's wire response) is unchanged — that field is a separate API contract pointing at the same value; renaming it would force every frontend deadline-renderer through a contract break that isn't part of this workstream. Test fixtures updated to the new SubmissionCode field name; live-DB tests updated to the post-mig-098 prefixed values (`inf.sod` → `upc.inf.cfi.sod` etc.). New submission_codes_shape_test asserts every active+published row matches the 4+-segment proceeding-prefixed shape (sibling of TestProceedingCodeShape; mirrors mig 098 §6.1). go build ./... clean. go test ./internal/... green.
434 lines
16 KiB
Go
434 lines
16 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|