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.
339 lines
12 KiB
Go
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
|
|
}
|