Files
paliad/internal/services/lookup_events_test.go
mAi 3a4e99cb92 feat(deadline-system): P1 — upc.apl re-split into merits/cost/order (m/paliad#149)
Phase 2 P1 / m's Q5 divergence (2026-05-27, verbatim):

  "Reverse the unification as suggested in 3. They are different
   proceedings, I only wanted the approach to be unified in the
   'determinator' — but they are actually different proceedings!"

Mig 155 reverts the mig-096 unification:

  Before: id=160 upc.apl.unified active (16 rules), id=11/19/20 inactive
  After:  id=11 upc.apl.merits (7 rules), id=19 upc.apl.cost (2 rules),
          id=20 upc.apl.order (7 rules) all active; id=160 inactive

The 16 rules under id=160 split cleanly by event_code prefix; all 10
parent_id edges among them are bucket-local (pre-flight audit), so
the tree shape survives the rebind unchanged.

Spawn FK retarget: pi.cfi.appeal_spawn flips from 11 (merits) → 20
(orders track) per design §3.1 — PI appeals land on orders, not
merits. The inf/rev/dmgs spawns keep target=11 (merits), now active.

Determinator routing layer (proceeding_mapping.go) keeps its single
"Berufung" front door per m's intent — only the data shape changes.

Pre-flight verified: 0 projects bound to id=160, 0 scenarios reference
upc.apl. Zero data migration on the project side.

Tests: lookup_events_test.go assertions on the three appeal_target
buckets updated to the new codes (endentscheidung → upc.apl.merits,
schadensbemessung → upc.apl.merits, bucheinsicht → upc.apl.order).
Same rule set, post-split coordinates.

Snapshot regen (pkg/litigationplanner/embedded/upc/) deferred: the
current snapshot only contains inf+rev so the apl re-split doesn't
shift its contents; regenerating would surface unrelated active PTs
and pollute this slice. Tracked as a follow-up.

Verified: go vet clean, go test ./internal/services/... -run
LookupEvents|proceeding_codes clean.

Design: docs/design-deadline-system-revision-2026-05-27.md §3.1
(re-split mig), §1.3 (spawn graph post-Q5). t-paliad-331.
2026-05-27 15:11:48 +02:00

225 lines
7.3 KiB
Go

package services
import (
"context"
"os"
"testing"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/db"
lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
)
// TestLookupEvents covers the multi-axis catalog query API from Slice
// B2 (m/paliad#124 §18.2). Skipped when TEST_DATABASE_URL is unset,
// mirroring TestCalculateRule.
//
// Cases:
// - jurisdiction=UPC, depth=all-following → every active+published
// UPC rule, anchor depth=1 for all (no parent_id outside the
// filtered set lights up depth>1 because the entire UPC subset is
// a single anchor cohort).
// - proceeding_type_id (upc.inf.cfi) + party=defendant + depth=next
// → defendant rules in upc.inf.cfi at depth=1 + direct children
// of those at depth=2.
// - unknown jurisdiction value → silently ignored, no filter applied.
// - empty axes → all rules (no filter on any axis).
func TestLookupEvents(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)
catalog := &paliadCatalog{rules: rules}
t.Run("jurisdiction=UPC, all-following returns the UPC corpus", func(t *testing.T) {
matches, err := catalog.LookupEvents(ctx, lp.EventLookupAxes{
Jurisdiction: "UPC",
}, lp.EventLookupDepthAllFollowing)
if err != nil {
t.Fatalf("LookupEvents: %v", err)
}
if len(matches) == 0 {
t.Fatal("expected non-empty UPC corpus")
}
// Every match must be a UPC rule.
for _, m := range matches {
if m.ProceedingType.Jurisdiction == nil || *m.ProceedingType.Jurisdiction != "UPC" {
t.Errorf("non-UPC row leaked into UPC-axis query: code=%s jurisdiction=%v",
m.ProceedingType.Code, m.ProceedingType.Jurisdiction)
}
if m.DepthFromAnchor < 1 {
t.Errorf("depth=%d for rule %s, want >= 1", m.DepthFromAnchor, m.Rule.ID)
}
}
})
t.Run("party=defendant scopes to defendant rules", func(t *testing.T) {
matches, err := catalog.LookupEvents(ctx, lp.EventLookupAxes{
Jurisdiction: "UPC",
Party: "defendant",
}, lp.EventLookupDepthNext)
if err != nil {
t.Fatalf("LookupEvents: %v", err)
}
if len(matches) == 0 {
t.Fatal("expected at least one defendant rule across the UPC corpus")
}
// Anchor matches (depth=1) must be primary_party=defendant.
// Depth=2 children appear under EventLookupDepthNext only as
// expansion from anchors — they may carry any party.
for _, m := range matches {
if m.DepthFromAnchor != 1 {
continue
}
if m.Rule.PrimaryParty == nil || *m.Rule.PrimaryParty != "defendant" {
t.Errorf("anchor row %s (depth=1) is not defendant: %v",
m.Rule.Name, m.Rule.PrimaryParty)
}
}
})
t.Run("unknown jurisdiction value silently falls through", func(t *testing.T) {
matchesAll, err := catalog.LookupEvents(ctx, lp.EventLookupAxes{},
lp.EventLookupDepthAllFollowing)
if err != nil {
t.Fatalf("LookupEvents (all): %v", err)
}
matchesUnknown, err := catalog.LookupEvents(ctx, lp.EventLookupAxes{
Jurisdiction: "XX-not-a-real-jurisdiction",
}, lp.EventLookupDepthAllFollowing)
if err != nil {
t.Fatalf("LookupEvents (unknown): %v", err)
}
if len(matchesAll) != len(matchesUnknown) {
t.Errorf("unknown jurisdiction should fall through to no-filter; got %d vs all-axes %d",
len(matchesUnknown), len(matchesAll))
}
})
t.Run("appeal_target=endentscheidung returns upc.apl merits rules", func(t *testing.T) {
matches, err := catalog.LookupEvents(ctx, lp.EventLookupAxes{
Jurisdiction: "UPC",
AppealTarget: lp.AppealTargetEndentscheidung,
}, lp.EventLookupDepthAllFollowing)
if err != nil {
t.Fatalf("LookupEvents: %v", err)
}
// Should hit the 7 merits-track rules that carry
// applies_to_target={endentscheidung} (Slice B1 mig 134).
// Post-mig 155 (m/paliad#149 P1): the unified upc.apl was split
// back into merits/cost/order — the endentscheidung anchors live
// under upc.apl.merits (id=11).
if len(matches) == 0 {
t.Fatal("expected upc.apl.merits endentscheidung rules after B1 mig")
}
for _, m := range matches {
if m.DepthFromAnchor != 1 {
continue // children of anchors may be from other targets
}
found := false
for _, t := range m.Rule.AppliesToTarget {
if t == lp.AppealTargetEndentscheidung {
found = true
break
}
}
if !found {
t.Errorf("anchor row %s missing endentscheidung target: %v",
m.Rule.Name, m.Rule.AppliesToTarget)
}
if m.ProceedingType.Code != "upc.apl.merits" {
t.Errorf("anchor row %s came from %s, want upc.apl.merits",
m.Rule.Name, m.ProceedingType.Code)
}
}
})
t.Run("appeal_target=schadensbemessung returns upc.apl merits rules (mig 138 backfill)", func(t *testing.T) {
matches, err := catalog.LookupEvents(ctx, lp.EventLookupAxes{
Jurisdiction: "UPC",
AppealTarget: lp.AppealTargetSchadensbemessung,
}, lp.EventLookupDepthAllFollowing)
if err != nil {
t.Fatalf("LookupEvents: %v", err)
}
// mig 138 (t-paliad-303, m/paliad#134) extends the 7 merits-track
// rules with applies_to_target ⊇ {schadensbemessung} because
// R.224 is uniform across substantive R.118 decisions. Post-mig
// 155 the merits track lives at upc.apl.merits (id=11).
if len(matches) == 0 {
t.Fatal("expected upc.apl.merits schadensbemessung rules after mig 138 backfill")
}
for _, m := range matches {
if m.DepthFromAnchor != 1 {
continue
}
found := false
for _, t := range m.Rule.AppliesToTarget {
if t == lp.AppealTargetSchadensbemessung {
found = true
break
}
}
if !found {
t.Errorf("anchor row %s missing schadensbemessung target: %v",
m.Rule.Name, m.Rule.AppliesToTarget)
}
if m.ProceedingType.Code != "upc.apl.merits" {
t.Errorf("anchor row %s came from %s, want upc.apl.merits",
m.Rule.Name, m.ProceedingType.Code)
}
}
})
t.Run("appeal_target=bucheinsicht returns upc.apl order rules (mig 138 backfill)", func(t *testing.T) {
matches, err := catalog.LookupEvents(ctx, lp.EventLookupAxes{
Jurisdiction: "UPC",
AppealTarget: lp.AppealTargetBucheinsicht,
}, lp.EventLookupDepthAllFollowing)
if err != nil {
t.Fatalf("LookupEvents: %v", err)
}
// mig 138 (t-paliad-303, m/paliad#134) extends the 7 order-track
// rules with applies_to_target ⊇ {bucheinsicht} because R.220.2 /
// R.224.2.b / R.235.2 / R.237 / R.238.2 are uniform across the
// orders they appeal. Post-mig 155 the order track lives at
// upc.apl.order (id=20).
if len(matches) == 0 {
t.Fatal("expected upc.apl.order bucheinsicht rules after mig 138 backfill")
}
for _, m := range matches {
if m.DepthFromAnchor != 1 {
continue
}
found := false
for _, t := range m.Rule.AppliesToTarget {
if t == lp.AppealTargetBucheinsicht {
found = true
break
}
}
if !found {
t.Errorf("anchor row %s missing bucheinsicht target: %v",
m.Rule.Name, m.Rule.AppliesToTarget)
}
if m.ProceedingType.Code != "upc.apl.order" {
t.Errorf("anchor row %s came from %s, want upc.apl.order",
m.Rule.Name, m.ProceedingType.Code)
}
}
})
}