Files
paliad/internal/services/fristenrechner_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

453 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.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: "upc.inf.cfi.sod",
TriggerDate: "2026-01-15",
})
if err != nil {
t.Fatalf("CalculateRule: %v", err)
}
if got.IsCourtSet {
t.Errorf("upc.inf.cfi.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: "upc.inf.cfi.decision",
TriggerDate: "2026-01-15",
})
if err != nil {
t.Fatalf("CalculateRule: %v", err)
}
if !got.IsCourtSet {
t.Errorf("upc.inf.cfi.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) {
// upc.inf.cfi.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: "upc.inf.cfi.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: "upc.inf.cfi.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: "upc.inf.cfi.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")
}
}