Files
paliad/internal/services/rule_editor_service_test.go
mAi bc5b3557d0 feat(t-paliad-209): rename DeadlineRule.Code → SubmissionCode across Go layer
Workstream B Go sweep — matches mig 098. Every place the deadline-rules
service reads/writes the per-rule identifier now uses the new column
name and the new struct field. Distinct from rule_code (legal citation)
and from proceeding_types.code (the proceeding's 3-segment code).

Touch points:
- models.DeadlineRule.Code → SubmissionCode (db + json tags renamed
  in lockstep — JSON contract `submission_code` is the new shape).
- deadline_rule_service: ruleColumns SELECT list updated.
- rule_editor_service: CreateRuleInput.Code → SubmissionCode (json tag
  too), INSERT + CloneAsDraft SELECT updated.
- projection_service: lookupRuleByCode → lookupRuleBySubmissionCode
  (SQL WHERE clause + error message); every r.Code / parent.Code /
  rule.Code / first.Code / src.rule.Code read renamed.
- fristenrechner: r.Code / prev.Code / rule.Code reads renamed in
  Calculate (parent-anchor + override-key + computed-by-code map) and
  in CalculateRule's LocalCode emission; the proceeding-code+submission-
  code resolver query uses `submission_code = $2`.
- event_trigger_service / deadline_calculator: r.Code reads renamed.

UIDeadline.Code (the calculator's wire response) is unchanged — that
field is a separate API contract pointing at the same value; renaming
it would force every frontend deadline-renderer through a contract
break that isn't part of this workstream.

Test fixtures updated to the new SubmissionCode field name; live-DB
tests updated to the post-mig-098 prefixed values (`inf.sod` →
`upc.inf.cfi.sod` etc.). New submission_codes_shape_test asserts
every active+published row matches the 4+-segment proceeding-prefixed
shape (sibling of TestProceedingCodeShape; mirrors mig 098 §6.1).

go build ./... clean. go test ./internal/... green.
2026-05-18 15:06:04 +02:00

339 lines
12 KiB
Go

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
}