test(t-paliad-191): rule-editor lifecycle + preview coverage
Live-DB tests (TEST_DATABASE_URL-gated) for Phase 3 Slice 11a:
TestRuleEditorService_Lifecycle — full create→update→publish→archive
→restore round-trip on synthetic fixtures (SLICE11A_TEST_PT
proceeding + rules). Asserts:
1. Create returns lifecycle_state='draft' with published_at=NULL.
2. UpdateDraft on a draft succeeds and lands the patch.
3. CloneAsDraft from a published row creates a new draft with
draft_of pointing at the source.
4. Publish flips draft → published, sets published_at, AND archives
the cloned-from peer (verified by re-reading the peer's
lifecycle_state post-publish).
5. Archive flips published → archived.
6. Restore flips archived → published.
7. ListAudit returns ≥ 3 rows newest-first with non-empty reason
strings (the mig 079 trigger captured them).
8. Empty audit_reason on UpdateDraft → ErrAuditReasonRequired.
9. UpdateDraft on a published row → ErrInvalidLifecycleState.
10. Restore on a non-archived row → ErrInvalidLifecycleState.
TestRuleEditorService_Preview — calculator override hook coverage
(SLICE11A_PREVIEW_PT proceeding + a published rule). Clone the
root rule, patch DurationValue 30 → 60 on the draft, call Preview
at trigger_date=2026-01-15. Asserts:
- Baseline Calculate (no overrides) returns the published rule's
dueDate (~30 days after trigger).
- Preview returns a DIFFERENT dueDate (substitutes the draft's
60-day duration via RuleOverrides) — sanity check that the
override pipeline reached the calculator and shifted the date.
- Both responses are non-empty (the rule is reachable).
Cleanup: WHERE name LIKE 'SLICE11A_TEST_%' / 'SLICE11A_PREVIEW_%'
AND code = 'SLICE11A_TEST_PT' / 'SLICE11A_PREVIEW_PT' so production
rules are untouched. audit_reason set on every seed / cleanup write
so the mig 079 trigger doesn't reject the seed transactions.
Build clean, full local test suite green; this test skips when
TEST_DATABASE_URL is unset.
This commit is contained in:
337
internal/services/rule_editor_service_test.go
Normal file
337
internal/services/rule_editor_service_test.go
Normal file
@@ -0,0 +1,337 @@
|
||||
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,
|
||||
Code: 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)
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx, `
|
||||
INSERT INTO paliad.deadline_rules
|
||||
(id, proceeding_type_id, code, name, name_en,
|
||||
duration_value, duration_unit, timing,
|
||||
is_mandatory, is_optional, 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',
|
||||
true, false, 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
|
||||
}
|
||||
Reference in New Issue
Block a user