Files
paliad/internal/services/fristenrechner_test.go
mAi 216abbfc98 feat(t-paliad-206): switch Go layer to lowercase dot-form proceeding codes
Sweeps internal/services + internal/handlers + internal/models to use
the new proceeding codes landed by mig 096. Stable Code* constants
live in internal/services/proceeding_mapping.go so a future rename
needs to touch one file.

Substantive changes:
- proceeding_mapping.go gains ResolveCounterclaimRouting() — the
  cascade resolver that routes upc.ccr.cfi (illustrative peer) back
  to upc.inf.cfi with with_ccr=true as default flag (design doc S1).
- deadline_search_service.go forum-bucket map updated; upc.ccr.cfi
  added to upc_cfi since it is a CFI peer.
- project_service.go CreateCounterclaim default lookup parameterised
  so the SQL string carries the constant, not a literal.
- proceeding_codes_shape_test.go: new file. Validates the shape
  regex standalone (always runs) and walks live DB rows asserting
  every active fristenrechner row matches the new shape + every
  stable Code* constant resolves to exactly one active row.

Comments and test fixtures throughout the Go tree updated to the
new shape. Tests pass under `go test ./internal/... -short`.
2026-05-18 12:13:24 +02:00

452 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package services
import (
"context"
"os"
"testing"
"time"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/db"
)
// Phase 3 Slice 4 (t-paliad-185) dropped isCourtDeterminedRule: the
// is_court_set column (mig 078) backfilled in Slice 2 (mig 082) is now
// the source-of-truth. Calculate reads r.IsCourtSet directly. The
// runtime equivalence of the old heuristic vs the column was verified
// by the Slice 2 backfill integrity test (priority + is_court_set +
// condition_expr). The seven-case discrimination matrix the old test
// exercised lives now as the migration 082 WHERE predicate.
// TestAllFlagsSet covers the t-paliad-131 condition_flag text→text[]
// migration semantic. A rule's flags array gates rendering: every
// element of the array must be present in the caller's flag set, OR
// the rule is suppressed (when alt_duration_value is NULL) or rendered
// without alt swap (when alt_duration_value is non-NULL — legacy
// with_ccr pattern).
func TestAllFlagsSet(t *testing.T) {
mkSet := func(fs ...string) map[string]struct{} {
s := make(map[string]struct{}, len(fs))
for _, f := range fs {
s[f] = struct{}{}
}
return s
}
cases := []struct {
name string
required []string
set map[string]struct{}
want bool
}{
{"empty required → true (unconditional rule)", nil, mkSet(), true},
{"empty required → true even with flags set", nil, mkSet("with_ccr"), true},
{"single flag, present → true (legacy with_ccr pattern)", []string{"with_ccr"}, mkSet("with_ccr"), true},
{"single flag, absent → false", []string{"with_ccr"}, mkSet(), false},
{"single flag, other present → false", []string{"with_ccr"}, mkSet("with_amend"), false},
{"two flags, both present → true (upc.inf.cfi nested)", []string{"with_ccr", "with_amend"}, mkSet("with_ccr", "with_amend"), true},
{"two flags, only one present → false", []string{"with_ccr", "with_amend"}, mkSet("with_ccr"), false},
{"two flags, both present + extra → true (extra flags don't matter)", []string{"with_ccr", "with_amend"}, mkSet("with_ccr", "with_amend", "with_cci"), true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := allFlagsSet(tc.required, tc.set); got != tc.want {
t.Errorf("allFlagsSet(%v, %v) = %v, want %v", tc.required, tc.set, got, tc.want)
}
})
}
}
// TestCalculateRule covers the v4 (t-paliad-136 Phase B) single-rule
// calculator that backs POST /api/tools/fristenrechner/calculate-rule.
// Distinct from Calculate: no parent-chain walk; the trigger date is
// the immediate parent event's effective date, and the calc applies
// the rule's duration + holiday adjustment directly.
//
// Skipped when TEST_DATABASE_URL is unset, mirroring TestDeadlineSearch.
func TestCalculateRule(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)
rules := NewDeadlineRuleService(pool)
courts := NewCourtService(pool)
svc := NewFristenrechnerService(rules, holidays, courts)
t.Run("plain rule calc — upc.inf.cfi inf.sod, R.23(1), 3 months", func(t *testing.T) {
// 2026-01-15 + 3 months = 2026-04-15. No vacation overlap.
got, err := svc.CalculateRule(ctx, CalcRuleParams{
ProceedingCode: CodeUPCInfringement,
RuleLocalCode: "inf.sod",
TriggerDate: "2026-01-15",
})
if err != nil {
t.Fatalf("CalculateRule: %v", err)
}
if got.IsCourtSet {
t.Errorf("inf.sod is not court-set; got IsCourtSet=true")
}
if got.DueDate != "2026-04-15" {
t.Errorf("dueDate = %q, want 2026-04-15", got.DueDate)
}
if got.Rule.LegalSourceDisplay != "UPC RoP R.23(1)" {
t.Errorf("legalSourceDisplay = %q, want UPC RoP R.23(1)", got.Rule.LegalSourceDisplay)
}
if got.Proceeding.Code != CodeUPCInfringement {
t.Errorf("proceeding code = %q, want upc.inf.cfi", got.Proceeding.Code)
}
})
t.Run("court-determined rule → IsCourtSet=true, no dueDate", func(t *testing.T) {
got, err := svc.CalculateRule(ctx, CalcRuleParams{
ProceedingCode: CodeUPCInfringement,
RuleLocalCode: "inf.decision",
TriggerDate: "2026-01-15",
})
if err != nil {
t.Fatalf("CalculateRule: %v", err)
}
if !got.IsCourtSet {
t.Errorf("inf.decision should be court-set; got IsCourtSet=false")
}
if got.DueDate != "" {
t.Errorf("court-set dueDate = %q, want empty", got.DueDate)
}
})
t.Run("flag-conditional rule surfaces FlagsRequired even when not satisfied", func(t *testing.T) {
// inf.def_to_ccr requires with_ccr. Without the flag, FlagsRequired
// is still surfaced so the UI can render the checkbox.
got, err := svc.CalculateRule(ctx, CalcRuleParams{
ProceedingCode: CodeUPCInfringement,
RuleLocalCode: "inf.def_to_ccr",
TriggerDate: "2026-01-15",
})
if err != nil {
t.Fatalf("CalculateRule: %v", err)
}
if len(got.FlagsRequired) == 0 || got.FlagsRequired[0] != "with_ccr" {
t.Errorf("FlagsRequired = %v, want [with_ccr]", got.FlagsRequired)
}
if len(got.FlagsApplied) != 0 {
t.Errorf("FlagsApplied = %v, want empty (flag not supplied)", got.FlagsApplied)
}
})
t.Run("flag-conditional rule with flag → FlagsApplied populated", func(t *testing.T) {
got, err := svc.CalculateRule(ctx, CalcRuleParams{
ProceedingCode: CodeUPCInfringement,
RuleLocalCode: "inf.def_to_ccr",
TriggerDate: "2026-01-15",
Flags: []string{"with_ccr"},
})
if err != nil {
t.Fatalf("CalculateRule: %v", err)
}
if len(got.FlagsApplied) == 0 || got.FlagsApplied[0] != "with_ccr" {
t.Errorf("FlagsApplied = %v, want [with_ccr]", got.FlagsApplied)
}
})
t.Run("missing TriggerDate → error", func(t *testing.T) {
_, err := svc.CalculateRule(ctx, CalcRuleParams{
ProceedingCode: CodeUPCInfringement,
RuleLocalCode: "inf.sod",
TriggerDate: "",
})
if err == nil {
t.Errorf("expected error for empty triggerDate")
}
})
t.Run("unknown rule → ErrUnknownRule", func(t *testing.T) {
_, err := svc.CalculateRule(ctx, CalcRuleParams{
ProceedingCode: CodeUPCInfringement,
RuleLocalCode: "totally.fake",
TriggerDate: "2026-01-15",
})
if err == nil {
t.Errorf("expected ErrUnknownRule, got nil")
}
})
}
// TestEvalConditionExpr covers the Phase 3 Slice 4 (t-paliad-185)
// jsonb gate evaluator. Long-form grammar per design §2.4: leaf
// {"flag":"X"}, AND / OR / NOT compositions. Single-flag values pass
// through unwrapped. NULL / empty expression falls back to
// condition_flag AND-semantics.
func TestEvalConditionExpr(t *testing.T) {
mkSet := func(fs ...string) map[string]struct{} {
m := make(map[string]struct{}, len(fs))
for _, f := range fs {
m[f] = struct{}{}
}
return m
}
cases := []struct {
name string
expr string
flags map[string]struct{}
want bool
}{
// NULL / empty / "null" expr → unconditional. Slice 9 removed
// the legacy condition_flag fallback that used to make this
// branch return false on flags-not-met — the column is gone.
{"empty expr → unconditional", "", mkSet(), true},
{"empty expr with flags set → unconditional", "", mkSet("with_ccr"), true},
{"literal null → unconditional", "null", mkSet(), true},
// Single-flag leaf (mig 084 unwrapped form for [single]).
{"single-flag leaf present → true", `{"flag":"with_ccr"}`, mkSet("with_ccr"), true},
{"single-flag leaf absent → false", `{"flag":"with_ccr"}`, mkSet("with_amend"), false},
// AND.
{"and(a, b) both present → true",
`{"op":"and","args":[{"flag":"with_ccr"},{"flag":"with_amend"}]}`,
mkSet("with_ccr", "with_amend"), true},
{"and(a, b) one absent → false",
`{"op":"and","args":[{"flag":"with_ccr"},{"flag":"with_amend"}]}`,
mkSet("with_ccr"), false},
{"and() empty args → true (vacuously)", `{"op":"and","args":[]}`, mkSet(), true},
// OR.
{"or(a, b) any present → true",
`{"op":"or","args":[{"flag":"with_ccr"},{"flag":"with_amend"}]}`,
mkSet("with_amend"), true},
{"or(a, b) none present → false",
`{"op":"or","args":[{"flag":"with_ccr"},{"flag":"with_amend"}]}`,
mkSet("with_cci"), false},
{"or() empty args → false (vacuously)", `{"op":"or","args":[]}`, mkSet(), false},
// NOT.
{"not(flag) absent → true",
`{"op":"not","args":[{"flag":"with_ccr"}]}`, mkSet(), true},
{"not(flag) present → false",
`{"op":"not","args":[{"flag":"with_ccr"}]}`, mkSet("with_ccr"), false},
// Nested.
{"and(or(a, b), not(c)) all conditions met → true",
`{"op":"and","args":[
{"op":"or","args":[{"flag":"with_ccr"},{"flag":"with_amend"}]},
{"op":"not","args":[{"flag":"expedited"}]}
]}`,
mkSet("with_amend"), true},
{"and(or(a, b), not(c)) NOT condition fails → false",
`{"op":"and","args":[
{"op":"or","args":[{"flag":"with_ccr"},{"flag":"with_amend"}]},
{"op":"not","args":[{"flag":"expedited"}]}
]}`,
mkSet("with_amend", "expedited"), false},
// Malformed → defensive true (rule still renders).
{"malformed JSON → true (defensive)", `{"op":"bro`, mkSet(), true},
{"unknown op → true (forward-compat)", `{"op":"xor","args":[{"flag":"with_ccr"}]}`, mkSet(), true},
{"not with two args → true (malformed NOT)", `{"op":"not","args":[{"flag":"a"},{"flag":"b"}]}`, mkSet(), true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := evalConditionExpr([]byte(tc.expr), tc.flags)
if got != tc.want {
t.Errorf("evalConditionExpr(%q, flags) = %v, want %v",
tc.expr, got, tc.want)
}
})
}
}
// TestWireFlagsFromPriority verifies the priority → (IsMandatory,
// IsOptional) reverse-mapping (Slice 4) matches the Slice 2 backfill so
// the wire shape stays byte-identical through the cutover. The four
// mappings + the safe default for unknown values are exhaustive.
func TestWireFlagsFromPriority(t *testing.T) {
cases := []struct {
priority string
wantMandatory bool
wantOptional bool
}{
{"mandatory", true, false},
{"optional", true, true},
{"recommended", false, false},
{"informational", false, false},
{"", true, false}, // safe default — never drop a rule
{"future_value", true, false},
}
for _, tc := range cases {
t.Run(tc.priority, func(t *testing.T) {
gotM, gotO := wireFlagsFromPriority(tc.priority)
if gotM != tc.wantMandatory || gotO != tc.wantOptional {
t.Errorf("wireFlagsFromPriority(%q) = (%v, %v), want (%v, %v)",
tc.priority, gotM, gotO, tc.wantMandatory, tc.wantOptional)
}
})
}
}
// TestApplyDuration_Matrix exercises the unified date-arithmetic helper
// across the 4 units × 3 timings × calendar/holiday matrix added in
// Slice 4. Mixes calendar units (days/weeks/months with weekend +
// holiday rollover) with working_days (skip-by-construction, no
// rollover).
func TestApplyDuration_Matrix(t *testing.T) {
hs := NewHolidayService(nil)
// Anchor: Thu 2026-04-30. Adjacent Fri (May 1) is Tag der Arbeit;
// Sat-Sun follow. Sequence exercises the rollover path.
thursday := time.Date(2026, 4, 30, 0, 0, 0, 0, time.UTC)
cases := []struct {
name string
base time.Time
value int
unit string
timing string
wantRaw time.Time
wantAdj time.Time
wantDidAdj bool
}{
{
name: "days/after — Thu + 1 calendar day → Fri (holiday) → adjusted to Mon",
base: thursday, value: 1, unit: "days", timing: "after",
wantRaw: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC),
wantAdj: time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC),
wantDidAdj: true,
},
{
name: "days/before — Thu - 1 → Wed (working) → no adjust",
base: thursday, value: 1, unit: "days", timing: "before",
wantRaw: time.Date(2026, 4, 29, 0, 0, 0, 0, time.UTC),
wantAdj: time.Date(2026, 4, 29, 0, 0, 0, 0, time.UTC),
wantDidAdj: false,
},
{
name: "weeks/after — Thu + 1 week → next Thu (working) → no adjust",
base: thursday, value: 1, unit: "weeks", timing: "after",
wantRaw: time.Date(2026, 5, 7, 0, 0, 0, 0, time.UTC),
wantAdj: time.Date(2026, 5, 7, 0, 0, 0, 0, time.UTC),
wantDidAdj: false,
},
{
name: "months/after — Thu Apr 30 + 1 month → Sat May 30 → adjusted to Mon Jun 1",
base: thursday, value: 1, unit: "months", timing: "after",
wantRaw: time.Date(2026, 5, 30, 0, 0, 0, 0, time.UTC),
wantAdj: time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC),
wantDidAdj: true,
},
{
name: "working_days/after — Thu + 1 wd → Mon (skip Fri holiday + weekend)",
base: thursday, value: 1, unit: "working_days", timing: "after",
wantRaw: time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC),
wantAdj: time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC),
wantDidAdj: false,
},
{
name: "working_days/before — Mon May 4 - 1 wd → Thu Apr 30 (skip Fri holiday)",
base: time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC),
value: 1, unit: "working_days", timing: "before",
wantRaw: time.Date(2026, 4, 30, 0, 0, 0, 0, time.UTC),
wantAdj: time.Date(2026, 4, 30, 0, 0, 0, 0, time.UTC),
wantDidAdj: false,
},
{
name: "unknown unit → identity (defensive)",
base: thursday, value: 5, unit: "fortnights", timing: "after",
wantRaw: thursday,
wantAdj: thursday, // adjusted = AdjustForNonWorkingDays(raw); thursday is a working day
wantDidAdj: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
raw, adj, didAdj, _ := applyDuration(tc.base, tc.value, tc.unit, tc.timing, "DE", "UPC", hs)
if !raw.Equal(tc.wantRaw) {
t.Errorf("raw: got %s, want %s", raw, tc.wantRaw)
}
if !adj.Equal(tc.wantAdj) {
t.Errorf("adjusted: got %s, want %s", adj, tc.wantAdj)
}
if didAdj != tc.wantDidAdj {
t.Errorf("didAdjust: got %v, want %v", didAdj, tc.wantDidAdj)
}
})
}
}
// TestUIDeadline_WireShape_Slice8 asserts Phase 3 Slice 8 (t-paliad-189)
// wire-shape additivity: UIResponse.Deadlines MUST carry the new
// `priority` + `conditionExpr` fields AND the legacy `isMandatory` +
// `isOptional` pair (derived via wireFlagsFromPriority) for one release.
// Slice 9 will drop the legacy fields — until then the response
// shape is a superset.
//
// Live DB required so the rules.List returns real (not synthetic)
// rules with the priority column populated by the Slice 2 backfill.
func TestUIDeadline_WireShape_Slice8(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)
rules := NewDeadlineRuleService(pool)
courts := NewCourtService(pool)
svc := NewFristenrechnerService(rules, holidays, courts)
resp, err := svc.Calculate(ctx, CodeUPCInfringement, "2026-01-15", CalcOptions{})
if err != nil {
t.Fatalf("Calculate upc.inf.cfi: %v", err)
}
if len(resp.Deadlines) == 0 {
t.Fatal("Calculate upc.inf.cfi returned no deadlines — seed-data missing?")
}
allowed := map[string]bool{
"mandatory": true, "recommended": true, "optional": true, "informational": true,
}
for _, d := range resp.Deadlines {
if !allowed[d.Priority] {
t.Errorf("rule %s: priority=%q not in unified enum", d.Code, d.Priority)
}
}
// At least one rule should carry a populated conditionExpr (the
// 17 with_ccr / with_amend / with_cci rules mig 084 translated).
// Spot-check that the field actually serialises as jsonb (non-empty
// bytes on at least one row).
var sawConditionExpr bool
for _, d := range resp.Deadlines {
if len(d.ConditionExpr) > 0 && string(d.ConditionExpr) != "null" {
sawConditionExpr = true
break
}
}
if !sawConditionExpr {
t.Logf("warning: no upc.inf.cfi rule had conditionExpr populated — verify mig 084 ran")
}
}