Files
paliad/internal/services/dual_write_test.go
mAi 38ebccc907
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
feat(services): Slice B.2 dual-write — RuleEditorService writes deadline_rules AND procedural_events / sequencing_rules / legal_sources (t-paliad-305 / m/paliad#93)
Keeps the parallel new tables (mig 136, Slice B.1) in lock-step with
the legacy paliad.deadline_rules table through every write path on
RuleEditorService. Read paths stay on deadline_rules in B.2 — B.3
flips them and stops legacy writes.

* internal/services/dual_write.go (new) —
  - syncDualWriteFromDeadlineRule(ctx, tx, id): idempotent UPSERT of
    legal_sources + procedural_events + sequencing_rules from the
    just-written deadline_rules row. Pure SQL projection, no Go-side
    struct mapping. Synthetic-code mint expression is byte-identical
    to mig 136 ('null.' || first 8 hex of stripped uuid).
  - syncDeadlineDualLinks(ctx, tx, deadlineID): mirrors a deadline's
    legacy rule_id back-link onto deadlines.procedural_event_id +
    sequencing_rule_id. Handles NULL rule_id naturally (collapses both
    new columns to NULL).
  - CheckDualWriteDrift(ctx, conn): nine read-only count queries +
    integrity joins. Returns DualWriteDriftReport. HasDrift() bool for
    log routing.
  - StartDualWriteDriftCheckLoop(ctx, conn, interval): goroutine ticker
    that runs CheckDualWriteDrift every `interval` (default 6h) for
    the lifetime of ctx. Clean run logs at INFO; drift at WARN with
    full report.

* internal/services/rule_editor_service.go —
  - Create / UpdateDraft / CloneAsDraft / Publish / flipLifecycle
    each call syncDualWriteFromDeadlineRule(ctx, tx, id) after the
    deadline_rules mutation, before tx.Commit. Publish syncs BOTH the
    published draft AND the cloned-from peer it just archived as a
    cascade. The audit_reason already set via setAuditReasonTx
    propagates to the new-table writes (same TX, same session).

* internal/services/rule_editor_orphans.go —
  - ResolveOrphan calls syncDeadlineDualLinks after UPDATE
    paliad.deadlines SET rule_id = $1, so the parallel new columns
    follow the legacy back-link.

* internal/services/deadline_service.go —
  - DeadlineService.Update calls syncDeadlineDualLinks when
    input.RuleSet is true (auto/custom rule swap from t-paliad-258).

* cmd/server/main.go —
  - Spawns StartDualWriteDriftCheckLoop alongside CalDAV sync and
    reminder scanner. Inherits bgCtx so the goroutine stops on
    SIGTERM. Interval 6h.

* internal/services/dual_write_test.go (new) —
  - TestDualWrite_RuleEditorLifecycle: Create → UpdateDraft → Publish
    → Archive, asserts the new tables mirror at each step. Final
    CheckDualWriteDrift returns zero drift.
  - TestDualWrite_SyntheticCodeForNullSubmission: rule created with
    submission_code=NULL gets a 'null.<8hex>' procedural_events row
    matching mig 136's mint expression byte-for-byte.

Scope decisions documented in the commit:

- B.2 keeps read paths on deadline_rules. paliadin's "Read paths fall
  back to legacy" reads as "reads stay on legacy as the safety net
  while drift-check validates the new tables". B.3 swaps reads to
  new tables only AND stops writing to deadline_rules — that's a
  separate slice per the design's §5.2/§5.3 split.

- B.2 does NOT modify submission_drafts, projection_service, the
  Fristenrechner calculator, the SubmissionVarsService, the
  Schriftsätze list query, or any other reader. They keep reading
  deadline_rules unchanged. The new tables are populated in parallel
  for B.3's cutover.

- Audit triggers on deadline_rules continue to fire as before. The
  new tables have no audit triggers yet (a later slice can add
  parallel audit rows once the new tables are authoritative).

- Drift-check uses default 6h interval — short enough that a broken
  dual-write surfaces within the same business day, long enough that
  the count-COUNTs don't churn the pool. Override via the caller in
  cmd/server.

Hard rules followed:
- audit_reason set on every TX before any deadline_rules mutation
  (existing pattern; new-table writes share the same reason).
- No destructive op (B.2 is strictly additive in behaviour).
- New helpers idempotent (UPSERT ON CONFLICT DO UPDATE) — safe to
  call twice, safe to re-run after a partial failure.

Build + vet clean. TestMigrations_NoDuplicateSlot passes.
2026-05-26 17:49:48 +02:00

301 lines
11 KiB
Go

// 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")
}
// 5. Drift check should return zero drift right after the dance.
report, err := CheckDualWriteDrift(ctx, pool)
if err != nil {
t.Fatalf("CheckDualWriteDrift: %v", err)
}
if report.HasDrift() {
t.Errorf("CheckDualWriteDrift unexpectedly flagged drift: %+v", report)
}
}
// 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))
}
}