package services import ( "context" "encoding/json" "os" "testing" "github.com/google/uuid" "github.com/jmoiron/sqlx" _ "github.com/lib/pq" "mgit.msbls.de/m/paliad/internal/db" ) // TestDeadlineRuleService_UnifiedColumns_CompatRead exercises the Phase 3 // Slice 1 (mig 078–080, t-paliad-182) additive-schema landing. // // What it validates: // // 1. Every Phase 3 column (trigger_event_id, spawn_proceeding_type_id, // combine_op, condition_expr, priority, is_court_set, // lifecycle_state, draft_of, published_at) is present on // paliad.deadline_rules after migrations apply and scans cleanly // into models.DeadlineRule. // // 2. The default migration values land: priority='mandatory', // is_court_set=false, lifecycle_state='published' on every pre- // Slice-1 row. New rows default the same way. // // 3. The audit trigger fires on UPDATE — exactly one // paliad.deadline_rule_audit row is written for an UPDATE that // supplies a reason via SET LOCAL paliad.audit_reason. // // 4. The audit trigger raises when paliad.audit_reason is unset on // UPDATE — Slice 2 backfills MUST set the reason or they fail // loudly. // // 5. paliad.projects.instance_level (mig 080) accepts NULL and the // three CHECK-allowed values, and rejects anything else. // // Skipped when TEST_DATABASE_URL is unset, mirroring audit_service_test.go. func TestDeadlineRuleService_UnifiedColumns_CompatRead(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() svc := NewDeadlineRuleService(pool) // ------------------------------------------------------------------- // 1. SELECT every column via the service's ruleColumns list. The list // must end the test green even though it now includes the Phase 3 // columns; if a scan error pops up we know a column name or Go // type slipped. // ------------------------------------------------------------------- rules, err := svc.List(ctx, nil) if err != nil { t.Fatalf("List: %v", err) } if len(rules) == 0 { t.Fatal("no rules returned; seed-data missing?") } // 2. Every row scans cleanly. Priority + is_court_set values depend on // whether Slice 2 (mig 082–084) has applied: pre-Slice-2 they carry // the mig 078 defaults (priority='mandatory', is_court_set=false); // post-Slice-2 they carry the backfilled values per design §2.3. // LifecycleState is set by mig 078 to 'published' for every row and // is unaffected by Slice 2. allowedPriorities := map[string]bool{ "mandatory": true, "recommended": true, "optional": true, "informational": true, } for _, r := range rules { if !allowedPriorities[r.Priority] { t.Errorf("rule %s: priority=%q not in enum", r.ID, r.Priority) } if r.LifecycleState != "published" { t.Errorf("rule %s: lifecycle_state=%q, want default 'published'", r.ID, r.LifecycleState) } } // ------------------------------------------------------------------- // 3 + 4. Audit trigger behaviour. Use a throwaway row in its own tx // so SET LOCAL is scoped to this test. // ------------------------------------------------------------------- // Pick any existing rule; we'll UPDATE its updated_at field with a // no-op-equivalent change (twice — once with reason, once without). target := rules[0] // Count the audit rows for this rule before we touch it. var beforeCount int if err := pool.GetContext(ctx, &beforeCount, `SELECT count(*) FROM paliad.deadline_rule_audit WHERE rule_id = $1`, target.ID); err != nil { t.Fatalf("count audit rows pre-update: %v", err) } // 3a. UPDATE WITH reason set — should succeed and write one audit row. tx, err := pool.BeginTxx(ctx, nil) if err != nil { t.Fatalf("begin tx: %v", err) } if _, err := tx.ExecContext(ctx, `SELECT set_config('paliad.audit_reason', 'test: compat-read audit smoke', true)`); err != nil { tx.Rollback() t.Fatalf("set audit reason: %v", err) } if _, err := tx.ExecContext(ctx, `UPDATE paliad.deadline_rules SET updated_at = now() WHERE id = $1`, target.ID); err != nil { tx.Rollback() t.Fatalf("update with reason: %v", err) } if err := tx.Commit(); err != nil { t.Fatalf("commit update-with-reason tx: %v", err) } var afterCount int if err := pool.GetContext(ctx, &afterCount, `SELECT count(*) FROM paliad.deadline_rule_audit WHERE rule_id = $1`, target.ID); err != nil { t.Fatalf("count audit rows post-update: %v", err) } if afterCount != beforeCount+1 { t.Errorf("audit-row count: before=%d, after=%d, want before+1", beforeCount, afterCount) } // Look up the audit row we just wrote: latest by changed_at, action='update'. var ( auditAction string auditReason string auditBefore json.RawMessage auditAfter json.RawMessage ) if err := pool.QueryRowxContext(ctx, `SELECT action, reason, before_json, after_json FROM paliad.deadline_rule_audit WHERE rule_id = $1 ORDER BY changed_at DESC LIMIT 1`, target.ID).Scan(&auditAction, &auditReason, &auditBefore, &auditAfter); err != nil { t.Fatalf("read latest audit row: %v", err) } if auditAction != "update" { t.Errorf("audit action=%q, want 'update'", auditAction) } if auditReason != "test: compat-read audit smoke" { t.Errorf("audit reason=%q, want the set_config value", auditReason) } if len(auditBefore) == 0 || len(auditAfter) == 0 { t.Errorf("audit before/after json missing: before=%q after=%q", auditBefore, auditAfter) } // 4. UPDATE WITHOUT reason — trigger must raise. tx2, err := pool.BeginTxx(ctx, nil) if err != nil { t.Fatalf("begin tx2: %v", err) } _, err = tx2.ExecContext(ctx, `UPDATE paliad.deadline_rules SET updated_at = now() WHERE id = $1`, target.ID) tx2.Rollback() if err == nil { t.Error("UPDATE without paliad.audit_reason should have raised, but succeeded") } // ------------------------------------------------------------------- // 5. paliad.projects.instance_level CHECK. // ------------------------------------------------------------------- userID := uuid.New() projectID := uuid.New() cleanup := func() { 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, 'instance-level-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, role, lang) VALUES ($1, 'instance-level-test@hlc.com', 'Instance Test', 'munich', 'associate', 'de')`, userID); err != nil { t.Fatalf("seed paliad.users: %v", err) } if _, err := pool.ExecContext(ctx, `INSERT INTO paliad.projects (id, type, path, title, status, created_by, instance_level) VALUES ($1, 'project', $1::text, 'Instance Test', 'active', $2, 'appeal')`, projectID, userID); err != nil { t.Fatalf("seed paliad.projects with instance_level='appeal': %v", err) } // Update to each allowed value should succeed; bogus value must fail. for _, lvl := range []string{"first", "cassation", "appeal"} { if _, err := pool.ExecContext(ctx, `UPDATE paliad.projects SET instance_level = $1 WHERE id = $2`, lvl, projectID); err != nil { t.Errorf("update instance_level=%q: %v", lvl, err) } } if _, err := pool.ExecContext(ctx, `UPDATE paliad.projects SET instance_level = 'final' WHERE id = $1`, projectID); err == nil { t.Error("instance_level='final' should violate CHECK constraint, but succeeded") } if _, err := pool.ExecContext(ctx, `UPDATE paliad.projects SET instance_level = NULL WHERE id = $1`, projectID); err != nil { t.Errorf("NULL instance_level should be allowed: %v", err) } } // TestDeadlineRuleService_BackfillIntegrity exercises the Phase 3 Slice 2 // (mig 082–084, t-paliad-183) backfills against the live corpus. // // What it validates: // // 1. is_court_set (mig 082): every rule with primary_party='court' OR // event_type IN ('hearing','decision','order') is true; every other // rule is false. Replicates isCourtDeterminedRule() exactly. // // 2. priority (mig 083): zero rules with NULL priority (CHECK guards // the schema, this is belt-and-braces). The four mapping branches // hold per design §2.3 — T/F→'mandatory', T/T→'optional', // F/T→'recommended', F/F→'recommended'. // // 3. condition_expr (mig 084): every rule with a non-empty // condition_flag has a non-NULL condition_expr; every rule with // NULL/empty condition_flag has NULL condition_expr. Single-flag // rules carry {"flag":""} (unwrapped); multi-flag rules // carry {"op":"and","args":[{"flag":""},...]} long form. // // Skipped when TEST_DATABASE_URL is unset. func TestDeadlineRuleService_BackfillIntegrity(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() // ------------------------------------------------------------------- // 1. is_court_set matches the live heuristic exactly. // ------------------------------------------------------------------- var mismatchCourt int if err := pool.GetContext(ctx, &mismatchCourt, ` SELECT count(*) FROM paliad.deadline_rules WHERE is_court_set <> ( primary_party = 'court' OR event_type IN ('hearing', 'decision', 'order') )`); err != nil { t.Fatalf("count court-mismatch rows: %v", err) } if mismatchCourt != 0 { t.Errorf("is_court_set diverges from heuristic on %d rules (mig 082 incomplete)", mismatchCourt) } // ------------------------------------------------------------------- // 2. priority backfill matches design §2.3. // ------------------------------------------------------------------- var nullPriority int if err := pool.GetContext(ctx, &nullPriority, `SELECT count(*) FROM paliad.deadline_rules WHERE priority IS NULL`); err != nil { t.Fatalf("count NULL priority rows: %v", err) } if nullPriority != 0 { t.Errorf("found %d rules with NULL priority — mig 083 incomplete or CHECK bypassed", nullPriority) } // Slice 9 (t-paliad-195) dropped the legacy is_mandatory / is_optional // columns; pre-drop the test bucketed by the legacy pair to verify // Slice 2's backfill mapping. Post-Slice-9 the only remaining // invariant is "every row has a valid priority enum value", which // the nullPriority check above already asserts. The pre-drop // snapshot lives in paliad.deadline_rules_pre_091; a rollback // could rerun the full bucket check there. // ------------------------------------------------------------------- // 3. condition_expr remains populated for the 17 originally-flagged // rules. We can no longer cross-check against condition_flag (the // column is gone in Slice 9) — instead, assert that the count of // non-NULL condition_expr rows matches the pre-mig-091 snapshot's // count of non-empty condition_flag rows (17 expected). If the // snapshot table is gone (a follow-up cleanup slice drops it), // skip this assertion gracefully. // ------------------------------------------------------------------- // Cross-check via the pre-mig-091 snapshot (defensive — Slice 9 // preserved it for rollback). If the snapshot is around, every // non-empty condition_flag row in the snapshot should map to a // non-NULL condition_expr in the live table. var snapshotExists bool _ = pool.GetContext(ctx, &snapshotExists, ` SELECT EXISTS (SELECT 1 FROM pg_tables WHERE schemaname='paliad' AND tablename='deadline_rules_pre_091')`) if snapshotExists { var orphans int if err := pool.GetContext(ctx, &orphans, ` SELECT count(*) FROM paliad.deadline_rules_pre_091 b JOIN paliad.deadline_rules dr ON dr.id = b.id WHERE b.condition_flag IS NOT NULL AND array_length(b.condition_flag, 1) > 0 AND dr.condition_expr IS NULL`); err != nil { t.Fatalf("snapshot cross-check: %v", err) } if orphans != 0 { t.Errorf("%d rules had condition_flag in snapshot but no condition_expr live — mig 084 missed them", orphans) } } }