// Slice B.2 dual-write tests (t-paliad-305 / m/paliad#93). // // Asserts the parallel projection — paliad.procedural_events + // paliad.sequencing_rules + paliad.legal_sources — stays in lock-step // with paliad.deadline_rules through the full RuleEditorService // lifecycle. Skipped when TEST_DATABASE_URL is unset. package services import ( "context" "os" "testing" "github.com/jmoiron/sqlx" _ "github.com/lib/pq" "mgit.msbls.de/m/paliad/internal/db" ) // TestDualWrite_RuleEditorLifecycle walks Create → UpdateDraft → // CloneAsDraft → Publish → Archive → Restore on RuleEditorService and // after each operation asserts that paliad.sequencing_rules has the // 1:1 mirror, paliad.procedural_events carries the projected identity, // and paliad.legal_sources carries the citation. func TestDualWrite_RuleEditorLifecycle(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() rules := NewDeadlineRuleService(pool) svc := NewRuleEditorService(pool, rules) cleanup := func() { pool.ExecContext(ctx, `SELECT set_config('paliad.audit_reason', 'slice b.2 test cleanup', true)`) // Order matters: sequencing_rules → procedural_events → legal_sources // (FK direction). deadline_rules cleanup last because mig 079 audit // trigger captures the DELETE. pool.ExecContext(ctx, `DELETE FROM paliad.sequencing_rules WHERE id IN ( SELECT id FROM paliad.deadline_rules WHERE name LIKE 'SLICEB2_TEST_%' )`) pool.ExecContext(ctx, `DELETE FROM paliad.procedural_events WHERE code LIKE 'sliceb2.%' OR code LIKE 'null.sliceb2%'`) pool.ExecContext(ctx, `DELETE FROM paliad.legal_sources WHERE citation LIKE 'SLICEB2.%'`) pool.ExecContext(ctx, `DELETE FROM paliad.deadline_rules WHERE name LIKE 'SLICEB2_TEST_%'`) pool.ExecContext(ctx, `DELETE FROM paliad.proceeding_types WHERE code = 'SLICEB2_TEST_PT'`) } cleanup() defer cleanup() var ptID int if err := pool.GetContext(ctx, &ptID, ` INSERT INTO paliad.proceeding_types (code, name, name_en, category, jurisdiction, is_active) VALUES ('SLICEB2_TEST_PT', 'Slice B.2 Test PT', 'Slice B.2 Test PT', 'fristenrechner', 'UPC', true) RETURNING id`); err != nil { t.Fatalf("seed proceeding_type: %v", err) } subCode := "sliceb2.create" legalSrc := "SLICEB2.PatG.1" // 1. Create — assert the parallel rows land. created, err := svc.Create(ctx, CreateRuleInput{ Name: "SLICEB2_TEST_create", NameEN: "SLICEB2_TEST_create_EN", ProceedingTypeID: &ptID, SubmissionCode: &subCode, LegalSource: &legalSrc, DurationValue: 30, DurationUnit: "days", Priority: "mandatory", }, "B.2 dual-write create test") if err != nil { t.Fatalf("Create: %v", err) } // legal_sources should now carry SLICEB2.PatG.1 var lsCount int if err := pool.GetContext(ctx, &lsCount, `SELECT COUNT(*) FROM paliad.legal_sources WHERE citation = $1`, legalSrc); err != nil { t.Fatalf("query legal_sources: %v", err) } if lsCount != 1 { t.Errorf("legal_sources after Create: got %d, want 1 for citation %q", lsCount, legalSrc) } // procedural_events should carry the submission_code var peName, peLifecycle string if err := pool.GetContext(ctx, &peName, `SELECT name FROM paliad.procedural_events WHERE code = $1`, subCode); err != nil { t.Fatalf("query procedural_events name: %v", err) } if peName != "SLICEB2_TEST_create" { t.Errorf("procedural_events.name after Create: got %q, want %q", peName, "SLICEB2_TEST_create") } if err := pool.GetContext(ctx, &peLifecycle, `SELECT lifecycle_state FROM paliad.procedural_events WHERE code = $1`, subCode); err != nil { t.Fatalf("query procedural_events lifecycle: %v", err) } if peLifecycle != "draft" { t.Errorf("procedural_events.lifecycle_state after Create: got %q, want %q", peLifecycle, "draft") } // sequencing_rules should have id = created.id and link to PE var srCount, srMatchPE int if err := pool.GetContext(ctx, &srCount, `SELECT COUNT(*) FROM paliad.sequencing_rules WHERE id = $1`, created.ID); err != nil { t.Fatalf("query sequencing_rules count: %v", err) } if srCount != 1 { t.Errorf("sequencing_rules row after Create: got %d, want 1 for id %s", srCount, created.ID) } if err := pool.GetContext(ctx, &srMatchPE, ` SELECT COUNT(*) FROM paliad.sequencing_rules sr JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id WHERE sr.id = $1 AND pe.code = $2`, created.ID, subCode); err != nil { t.Fatalf("query sr→pe join: %v", err) } if srMatchPE != 1 { t.Errorf("sequencing_rules.procedural_event_id after Create: got %d join hits, want 1", srMatchPE) } // 2. UpdateDraft — change name + legal_source. Assert propagation. newName := "SLICEB2_TEST_updated" newLegal := "SLICEB2.ZPO.2" _, err = svc.UpdateDraft(ctx, created.ID, RulePatch{ Name: &newName, LegalSource: &newLegal, }, "B.2 dual-write update test") if err != nil { t.Fatalf("UpdateDraft: %v", err) } var afterName string if err := pool.GetContext(ctx, &afterName, `SELECT name FROM paliad.procedural_events WHERE code = $1`, subCode); err != nil { t.Fatalf("query pe.name post-update: %v", err) } if afterName != newName { t.Errorf("procedural_events.name after UpdateDraft: got %q, want %q", afterName, newName) } // New citation must appear in legal_sources, and procedural_events.legal_source_id // must point at it (idempotent UPSERT — the old SLICEB2.PatG.1 row stays). var pePointsAtNewLegal int if err := pool.GetContext(ctx, &pePointsAtNewLegal, ` SELECT COUNT(*) FROM paliad.procedural_events pe JOIN paliad.legal_sources ls ON ls.id = pe.legal_source_id WHERE pe.code = $1 AND ls.citation = $2`, subCode, newLegal); err != nil { t.Fatalf("query pe→ls join: %v", err) } if pePointsAtNewLegal != 1 { t.Errorf("procedural_events.legal_source_id after UpdateDraft: got %d hits, want 1", pePointsAtNewLegal) } // 3. Publish — flip to published. Assert lifecycle mirror. _, err = svc.Publish(ctx, created.ID, "B.2 dual-write publish test") if err != nil { t.Fatalf("Publish: %v", err) } var srLifecycle, peLifecycleAfterPub string if err := pool.GetContext(ctx, &srLifecycle, `SELECT lifecycle_state FROM paliad.sequencing_rules WHERE id = $1`, created.ID); err != nil { t.Fatalf("query sr.lifecycle: %v", err) } if srLifecycle != "published" { t.Errorf("sequencing_rules.lifecycle_state after Publish: got %q, want %q", srLifecycle, "published") } if err := pool.GetContext(ctx, &peLifecycleAfterPub, `SELECT lifecycle_state FROM paliad.procedural_events WHERE code = $1`, subCode); err != nil { t.Fatalf("query pe.lifecycle post-publish: %v", err) } if peLifecycleAfterPub != "published" { t.Errorf("procedural_events.lifecycle_state after Publish: got %q, want %q", peLifecycleAfterPub, "published") } // 4. Archive — flip to archived. Assert mirror. _, err = svc.Archive(ctx, created.ID, "B.2 dual-write archive test") if err != nil { t.Fatalf("Archive: %v", err) } var srLifecycleArchived string if err := pool.GetContext(ctx, &srLifecycleArchived, `SELECT lifecycle_state FROM paliad.sequencing_rules WHERE id = $1`, created.ID); err != nil { t.Fatalf("query sr.lifecycle post-archive: %v", err) } if srLifecycleArchived != "archived" { t.Errorf("sequencing_rules.lifecycle_state after Archive: got %q, want %q", srLifecycleArchived, "archived") } // Slice B.4 (mig 140, t-paliad-305): the legacy paliad.deadline_rules // table is gone and so is CheckDualWriteDrift — there's no parallel // side to compare against. The INSTEAD OF triggers on the view // guarantee parity by construction (single TX fan-out from one // SQL write to three target tables). } // TestDualWrite_SyntheticCodeForNullSubmission asserts that a rule // created with submission_code=NULL gets a synthetic 'null.<8hex>' // procedural_events row matching mig 136's mint expression — so a new // draft without a code participates in the dual-write contract without // colliding with any code-bearing rule. func TestDualWrite_SyntheticCodeForNullSubmission(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() rules := NewDeadlineRuleService(pool) svc := NewRuleEditorService(pool, rules) cleanup := func() { pool.ExecContext(ctx, `SELECT set_config('paliad.audit_reason', 'slice b.2 null-code cleanup', true)`) pool.ExecContext(ctx, `DELETE FROM paliad.sequencing_rules WHERE id IN ( SELECT id FROM paliad.deadline_rules WHERE name = 'SLICEB2_TEST_nullcode' )`) // Synthetic PE rows are keyed off the rule's uuid; delete by name reference. pool.ExecContext(ctx, `DELETE FROM paliad.procedural_events WHERE code IN ( SELECT 'null.' || substring(replace(id::text, '-', ''), 1, 8) FROM paliad.deadline_rules WHERE name = 'SLICEB2_TEST_nullcode' )`) pool.ExecContext(ctx, `DELETE FROM paliad.deadline_rules WHERE name = 'SLICEB2_TEST_nullcode'`) pool.ExecContext(ctx, `DELETE FROM paliad.proceeding_types WHERE code = 'SLICEB2_NC_PT'`) } cleanup() defer cleanup() var ptID int if err := pool.GetContext(ctx, &ptID, ` INSERT INTO paliad.proceeding_types (code, name, name_en, category, jurisdiction, is_active) VALUES ('SLICEB2_NC_PT', 'NC PT', 'NC PT', 'fristenrechner', 'UPC', true) RETURNING id`); err != nil { t.Fatalf("seed proceeding_type: %v", err) } created, err := svc.Create(ctx, CreateRuleInput{ Name: "SLICEB2_TEST_nullcode", NameEN: "SLICEB2_TEST_nullcode_EN", ProceedingTypeID: &ptID, // SubmissionCode intentionally NIL → tests the synthetic-code branch. DurationValue: 5, DurationUnit: "days", Priority: "mandatory", }, "B.2 dual-write null-code test") if err != nil { t.Fatalf("Create: %v", err) } // Compute the expected synthetic code in the same way mig 136 / the // dual-write helper do — keep the expression in lock-step with the // SQL via this Go-side mirror. var expectedCode string if err := pool.GetContext(ctx, &expectedCode, `SELECT 'null.' || substring(replace(id::text, '-', ''), 1, 8) FROM paliad.deadline_rules WHERE id = $1`, created.ID); err != nil { t.Fatalf("compute expected synthetic code: %v", err) } var actualCode string if err := pool.GetContext(ctx, &actualCode, ` SELECT pe.code FROM paliad.procedural_events pe JOIN paliad.sequencing_rules sr ON sr.procedural_event_id = pe.id WHERE sr.id = $1`, created.ID); err != nil { t.Fatalf("query procedural_events via sequencing_rules: %v", err) } if actualCode != expectedCode { t.Errorf("synthetic code mismatch: got %q, want %q", actualCode, expectedCode) } if len(actualCode) != len("null.")+8 { t.Errorf("synthetic code length: got %d, want 13 (null.+8hex)", len(actualCode)) } }