Phase 3 Slice 9 test cleanup. Seeds + assertions no longer touch
the legacy columns (mig 091 dropped them).
- projection_service_test.go (Slice 7 fixtures): INSERT seeds
drop the is_mandatory / is_optional columns from the
paliad.deadline_rules column list. Defaults are fine; the
spawn-graph test doesn't read those.
- rule_editor_service_test.go (Slice 11a fixtures): same drop
on the SLICE11A_PREVIEW seed.
- fristenrechner_test.go (Slice 8 wire-shape assertion): drops
the wireFlagsFromPriority round-trip check (the bool pair is
no longer on the wire). The enum-membership invariant
survives. evalConditionExpr table-driven test rewritten —
legacy condition_flag fallback cases removed (the fallback
is gone in Slice 9), pure-jsonb cases retained.
- deadline_rule_service_test.go (Slice 2 backfill integrity):
legacy-pair bucket assertion dropped; the priority-non-NULL
invariant still holds via the CHECK constraint. The
condition_flag cross-check now joins the pre-mig-091 snapshot
when present (a future cleanup slice drops the snapshot
along with this code path).
Build + tests green.
333 lines
12 KiB
Go
333 lines
12 KiB
Go
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":"<name>"} (unwrapped); multi-flag rules
|
||
// carry {"op":"and","args":[{"flag":"<a>"},...]} 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)
|
||
}
|
||
}
|
||
}
|