package services import ( "context" "errors" "os" "testing" "github.com/google/uuid" "github.com/jmoiron/sqlx" _ "github.com/lib/pq" "mgit.msbls.de/m/paliad/internal/db" ) // TestRuleEditorService_Lifecycle exercises the Phase 3 Slice 11a // (t-paliad-191) rule-editor lifecycle end-to-end against a live DB. // Synthetic fixture: one proceeding type ("SLICE11A_TEST_PT") with // one rule that the editor walks through create → patch → clone → // publish → archive → restore. Asserts: // // 1. Create returns a draft (lifecycle_state='draft', published_at=NULL). // 2. UpdateDraft only works on drafts; ErrInvalidLifecycleState // on a non-draft. // 3. CloneAsDraft on a published row produces a new draft with // draft_of pointing at the source. // 4. Publish flips draft → published, sets published_at, archives // the cloned-from source. // 5. Archive flips published → archived. // 6. Restore flips archived → published, preserves the original // published_at when COALESCE applies. // 7. ListAudit returns rows in chronological-descending order with // non-empty reason strings (the mig 079 trigger captured them). // 8. Empty audit_reason → ErrAuditReasonRequired (400 in handler). // // Skipped when TEST_DATABASE_URL is unset. func TestRuleEditorService_Lifecycle(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 11a test cleanup', true)`) pool.ExecContext(ctx, `DELETE FROM paliad.deadline_rules WHERE name LIKE 'SLICE11A_TEST_%'`) pool.ExecContext(ctx, `DELETE FROM paliad.proceeding_types WHERE code = 'SLICE11A_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 ('SLICE11A_TEST_PT', 'Slice 11a Test PT', 'Slice 11a Test PT', 'fristenrechner', 'UPC', true) RETURNING id`); err != nil { t.Fatalf("seed proceeding_type: %v", err) } // 1. Create — initial draft. created, err := svc.Create(ctx, CreateRuleInput{ Name: "SLICE11A_TEST_initial", NameEN: "SLICE11A_TEST_initial_EN", ProceedingTypeID: &ptID, SubmissionCode: ptrString("s11a.initial"), DurationValue: 30, DurationUnit: "days", Priority: "mandatory", SequenceOrder: 0, }, "test: initial draft") if err != nil { t.Fatalf("Create: %v", err) } if created.LifecycleState != "draft" { t.Errorf("created lifecycle_state = %q, want draft", created.LifecycleState) } if created.PublishedAt != nil { t.Errorf("created PublishedAt should be nil; got %v", created.PublishedAt) } // 8. Empty audit_reason → ErrAuditReasonRequired. _, err = svc.UpdateDraft(ctx, created.ID, RulePatch{Name: ptrString("anything")}, "") if !errors.Is(err, ErrAuditReasonRequired) { t.Errorf("empty reason: want ErrAuditReasonRequired, got %v", err) } // 2a. UpdateDraft on a draft — succeeds. patched, err := svc.UpdateDraft(ctx, created.ID, RulePatch{ DurationValue: ptr(45), Priority: ptrString("recommended"), }, "test: tweak duration + priority") if err != nil { t.Fatalf("UpdateDraft: %v", err) } if patched.DurationValue != 45 { t.Errorf("patched DurationValue = %d, want 45", patched.DurationValue) } if patched.Priority != "recommended" { t.Errorf("patched Priority = %q, want recommended", patched.Priority) } // 4. Publish: flips draft → published, sets published_at. published, err := svc.Publish(ctx, created.ID, "test: ship to live") if err != nil { t.Fatalf("Publish: %v", err) } if published.LifecycleState != "published" { t.Errorf("published lifecycle_state = %q, want published", published.LifecycleState) } if published.PublishedAt == nil { t.Error("published PublishedAt is nil; want set") } // 2b. UpdateDraft on a published row — ErrInvalidLifecycleState. _, err = svc.UpdateDraft(ctx, published.ID, RulePatch{Name: ptrString("x")}, "test: should fail") if !errors.Is(err, ErrInvalidLifecycleState) { t.Errorf("UpdateDraft on published: want ErrInvalidLifecycleState, got %v", err) } // 3. CloneAsDraft on the published row → new draft with draft_of set. cloned, err := svc.CloneAsDraft(ctx, published.ID, "test: clone for edit") if err != nil { t.Fatalf("CloneAsDraft: %v", err) } if cloned.LifecycleState != "draft" { t.Errorf("cloned lifecycle_state = %q, want draft", cloned.LifecycleState) } if cloned.DraftOf == nil || *cloned.DraftOf != published.ID { t.Errorf("cloned DraftOf = %v, want %v", cloned.DraftOf, published.ID) } // 4b. Publish the clone: archives the original published peer. clonePublished, err := svc.Publish(ctx, cloned.ID, "test: ship the clone") if err != nil { t.Fatalf("Publish clone: %v", err) } if clonePublished.LifecycleState != "published" { t.Errorf("clonePublished lifecycle_state = %q, want published", clonePublished.LifecycleState) } // Verify the peer is now archived. peer, err := svc.GetByID(ctx, published.ID) if err != nil { t.Fatalf("re-read peer: %v", err) } if peer.LifecycleState != "archived" { t.Errorf("peer after clone-publish = %q, want archived", peer.LifecycleState) } // 5. Archive the new live row. archived, err := svc.Archive(ctx, clonePublished.ID, "test: archive new live") if err != nil { t.Fatalf("Archive: %v", err) } if archived.LifecycleState != "archived" { t.Errorf("archived lifecycle_state = %q, want archived", archived.LifecycleState) } // 6. Restore. restored, err := svc.Restore(ctx, clonePublished.ID, "test: restore from archive") if err != nil { t.Fatalf("Restore: %v", err) } if restored.LifecycleState != "published" { t.Errorf("restored lifecycle_state = %q, want published", restored.LifecycleState) } // 7. Audit log. audit, err := svc.ListAudit(ctx, clonePublished.ID, 0, 50) if err != nil { t.Fatalf("ListAudit: %v", err) } if len(audit) < 3 { // publish (create-by-clone via mig 079 trigger fires 'create'), // publish (update), archive (update), restore (update). At // least 3 distinct audit rows on this rule's id. t.Errorf("audit rows = %d, want >=3", len(audit)) } // Newest-first ordering. for i := 1; i < len(audit); i++ { if audit[i-1].ChangedAt.Before(audit[i].ChangedAt) { t.Errorf("audit not in DESC order at idx %d", i) } } // Reasons should be non-empty (mig 079 trigger captured them). for _, e := range audit { if e.Reason == "" { t.Errorf("audit row %s has empty reason", e.ID) } } // Restore-on-non-archived → ErrInvalidLifecycleState. _, err = svc.Restore(ctx, clonePublished.ID, "test: should fail (already published)") if !errors.Is(err, ErrInvalidLifecycleState) { t.Errorf("Restore on published: want ErrInvalidLifecycleState, got %v", err) } } // TestRuleEditorService_Preview asserts that the calculator's // RuleOverrides hook substitutes the draft for its published peer. // Synthetic fixture: 1 proceeding + 1 root rule (parent_id NULL, // duration=30 days). Clone the root, patch duration to 60, preview // → expect the dueDate offset by 60 days instead of 30. func TestRuleEditorService_Preview(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() holidays := NewHolidayService(pool) courts := NewCourtService(pool) rules := NewDeadlineRuleService(pool) fristen := NewFristenrechnerService(rules, holidays, courts) svc := NewRuleEditorService(pool, rules) cleanup := func() { pool.ExecContext(ctx, `SELECT set_config('paliad.audit_reason', 'slice 11a preview cleanup', true)`) pool.ExecContext(ctx, `DELETE FROM paliad.deadline_rules WHERE name LIKE 'SLICE11A_PREVIEW_%'`) pool.ExecContext(ctx, `DELETE FROM paliad.proceeding_types WHERE code = 'SLICE11A_PREVIEW_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 ('SLICE11A_PREVIEW_PT', 'Slice 11a Preview PT', 'Slice 11a Preview PT', 'fristenrechner', 'UPC', true) RETURNING id`); err != nil { t.Fatalf("seed proceeding_type: %v", err) } // Seed a published rule directly (skip the editor for the seed — // we want a deterministic published state to clone from). if _, err := pool.ExecContext(ctx, `SELECT set_config('paliad.audit_reason', 'slice 11a preview seed', true)`); err != nil { t.Fatalf("set audit reason: %v", err) } // Slice 9 (t-paliad-195) dropped is_mandatory / is_optional. if _, err := pool.ExecContext(ctx, ` INSERT INTO paliad.deadline_rules (id, proceeding_type_id, submission_code, name, name_en, duration_value, duration_unit, timing, is_court_set, is_spawn, priority, lifecycle_state, is_active, sequence_order, published_at, created_at, updated_at) VALUES (gen_random_uuid(), $1, 'preview.root', 'SLICE11A_PREVIEW_root', 'SLICE11A_PREVIEW_root_EN', 30, 'days', 'after', false, false, 'mandatory', 'published', true, 0, now(), now(), now())`, ptID); err != nil { t.Fatalf("seed published rule: %v", err) } // Look up the seeded rule. var rootID string if err := pool.GetContext(ctx, &rootID, ` SELECT id::text FROM paliad.deadline_rules WHERE proceeding_type_id = $1 AND name = 'SLICE11A_PREVIEW_root'`, ptID); err != nil { t.Fatalf("look up root rule: %v", err) } rootUUID := mustParseUUID(t, rootID) // Clone + patch the clone to duration=60. cloned, err := svc.CloneAsDraft(ctx, rootUUID, "preview test: clone for tweak") if err != nil { t.Fatalf("CloneAsDraft: %v", err) } if _, err := svc.UpdateDraft(ctx, cloned.ID, RulePatch{ DurationValue: ptr(60), }, "preview test: bump to 60d"); err != nil { t.Fatalf("UpdateDraft to 60d: %v", err) } // Compute the published baseline (30 days) for reference. baseResp, err := fristen.Calculate(ctx, "SLICE11A_PREVIEW_PT", "2026-01-15", CalcOptions{}) if err != nil { t.Fatalf("baseline Calculate: %v", err) } if len(baseResp.Deadlines) == 0 { t.Fatal("baseline returned no deadlines") } baseDue := baseResp.Deadlines[0].DueDate // Preview with the cloned draft (duration=60 — should give a // later date than the baseline). previewResp, err := svc.Preview(ctx, fristen, cloned.ID, "2026-01-15", nil, "") if err != nil { t.Fatalf("Preview: %v", err) } if len(previewResp.Deadlines) == 0 { t.Fatal("preview returned no deadlines") } previewDue := previewResp.Deadlines[0].DueDate if previewDue == baseDue { t.Errorf("preview should differ from baseline: both = %s", baseDue) } // Sanity: the preview's due date should be ~30 days later than // the baseline (60d vs 30d offset; rollover may shift a day or // two but never less than 25 days difference). t.Logf("baseline dueDate=%s, preview dueDate=%s", baseDue, previewDue) } func ptrString(s string) *string { return &s } func mustParseUUID(t *testing.T, s string) uuid.UUID { t.Helper() id, err := uuid.Parse(s) if err != nil { t.Fatalf("parse uuid %q: %v", s, err) } return id }