Files
paliad/internal/services/deadline_rule_service_test.go
mAi 7dae9b2216 test(t-paliad-195): adapt fixtures + assertions to post-drop shape
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.
2026-05-15 17:53:44 +02:00

333 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 078080, 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 082084) 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 082084, 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)
}
}
}