Files
paliad/internal/services/event_deadline_service_test.go
mAi d7bb238e46 test(t-paliad-185): table-driven unit tests for new helpers
Phase 3 Slice 4 test coverage. Adds:

  - TestEvalConditionExpr (20 sub-cases): AND/OR/NOT compositions,
    single-flag leaf, nested AND-of-OR-and-NOT, empty-args
    vacuous-truth semantics, NULL-expr → legacy condition_flag
    fallback (preserves the AND-of-flags behaviour for any
    pre-Slice-2-style row), malformed JSON / unknown op / malformed
    NOT all defensive-true (rule still renders).

  - TestWireFlagsFromPriority (6 sub-cases): exhaustive enum +
    safe-default for unknown values. Matches the reverse of the
    Slice 2 mig 083 backfill mapping.

  - TestApplyDuration_Matrix (7 sub-cases): 4 units × multiple
    timings × calendar/holiday rollover. Includes the
    Thu+1d-over-Tag-der-Arbeit edge that exercises the
    weekend+holiday cascade.

Test file housekeeping:

  - Drops TestIsCourtDeterminedRule (the function it tested no
    longer exists; equivalence is preserved by mig 082's WHERE
    predicate and verified by the Slice 2 backfill integrity test).
  - Drops the unused models import that becomes orphaned.
  - Renames the EventDeadlineService.applyDuration / addWorkingDays
    method-receiver tests to call the package-level functions
    directly. Same test names + expected dates; only the helper
    signature shifted.
  - Parity test still calls the same applyDuration body, now via
    the unified helper.

Full test suite green locally (live DB tests skip when
TEST_DATABASE_URL is unset, as ever).
2026-05-15 00:53:01 +02:00

313 lines
12 KiB
Go

package services
import (
"context"
"os"
"sort"
"testing"
"time"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/db"
)
// addWorkingDays + composite-rule semantics — pure-Go logic, no DB needed.
//
// Phase 3 Slice 4 (t-paliad-185) collapsed the prior method versions
// (s.addWorkingDays / s.applyDuration on *EventDeadlineService) into
// package-level helpers shared with FristenrechnerService. Tests now
// call them directly without a receiver.
func TestAddWorkingDays_SkipsWeekends(t *testing.T) {
hs := NewHolidayService(nil)
// 2026-04-30 = Thu. +3 wd: step → Fri May 1 (Tag der Arbeit, skip) → Sat
// (skip) → Sun (skip) → Mon May 4 = WD 1; → Tue May 5 = WD 2; → Wed
// May 6 = WD 3. So +3 wd = Wed 2026-05-06.
in := time.Date(2026, 4, 30, 0, 0, 0, 0, time.UTC)
got := addWorkingDays(in, 3, "DE", "UPC", hs)
want := time.Date(2026, 5, 6, 0, 0, 0, 0, time.UTC)
if !got.Equal(want) {
t.Errorf("addWorkingDays(+3): got %s, want %s", got, want)
}
}
func TestAddWorkingDays_SkipsHolidays(t *testing.T) {
hs := NewHolidayService(nil)
// 2026-04-30 = Thu. +1 wd = Fri 2026-05-01 = Tag der Arbeit (DE federal holiday).
// → skip → Sat (weekend) → skip → Sun (weekend) → skip → Mon 2026-05-04.
in := time.Date(2026, 4, 30, 0, 0, 0, 0, time.UTC)
got := addWorkingDays(in, 1, "DE", "UPC", hs)
want := time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC)
if !got.Equal(want) {
t.Errorf("addWorkingDays(+1) over Tag der Arbeit: got %s, want %s", got, want)
}
}
func TestAddWorkingDays_NegativeWalksBackward(t *testing.T) {
hs := NewHolidayService(nil)
// Mon 2026-05-04 - 2 wd = Thu 2026-04-30 (skipping Fri 2026-05-01 holiday).
in := time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC)
got := addWorkingDays(in, -2, "DE", "UPC", hs)
want := time.Date(2026, 4, 29, 0, 0, 0, 0, time.UTC)
if !got.Equal(want) {
t.Errorf("addWorkingDays(-2) over Tag der Arbeit: got %s, want %s", got, want)
}
}
func TestAddWorkingDays_Zero(t *testing.T) {
hs := NewHolidayService(nil)
// Day-zero convention: returns input unchanged, even if it's a weekend.
weekend := time.Date(2026, 5, 2, 0, 0, 0, 0, time.UTC) // Saturday
got := addWorkingDays(weekend, 0, "DE", "UPC", hs)
if !got.Equal(weekend) {
t.Errorf("addWorkingDays(0) on weekend: got %s, want %s (unchanged)", got, weekend)
}
}
func TestApplyDuration_WorkingDays_SkipsAdjustment(t *testing.T) {
hs := NewHolidayService(nil)
// working_days lands on a working day by construction → no further adjust.
// Thu 2026-04-30 + 1 wd = Mon 2026-05-04 (skipped Fri holiday + weekend).
in := time.Date(2026, 4, 30, 0, 0, 0, 0, time.UTC)
raw, adjusted, didAdjust, _ := applyDuration(in, 1, "working_days", "after", "DE", "UPC", hs)
want := time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC)
if !raw.Equal(want) {
t.Errorf("raw: got %s, want %s", raw, want)
}
if !adjusted.Equal(want) {
t.Errorf("adjusted: got %s, want %s", adjusted, want)
}
if didAdjust {
t.Error("working_days unit should report didAdjust=false")
}
}
func TestApplyDuration_BeforeTiming(t *testing.T) {
hs := NewHolidayService(nil)
// Wed 2026-04-15 - 2 weeks = Wed 2026-04-01. Working day → no adjust.
in := time.Date(2026, 4, 15, 0, 0, 0, 0, time.UTC)
raw, adjusted, _, _ := applyDuration(in, 2, "weeks", "before", "DE", "UPC", hs)
want := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)
if !raw.Equal(want) {
t.Errorf("raw: got %s, want %s", raw, want)
}
if !adjusted.Equal(want) {
t.Errorf("adjusted: got %s, want %s", adjusted, want)
}
}
// Composite-rule test: R.198/R.213 "31d OR 20 working_days, whichever is longer".
// We hand-compute the two legs and pick max via the same logic as Calculate.
func TestComposite_R198_LongerLegWins(t *testing.T) {
hs := NewHolidayService(nil)
in := time.Date(2026, 4, 30, 0, 0, 0, 0, time.UTC)
_, baseAdj, _, _ := applyDuration(in, 31, "days", "after", "DE", "UPC", hs)
_, altAdj, _, _ := applyDuration(in, 20, "working_days", "after", "DE", "UPC", hs)
// 31 calendar days from Thu 2026-04-30 = Sun 2026-05-31 → adjust to Mon 2026-06-01.
// 20 working days from Thu 2026-04-30 ≈ early June (skipping May 1 holiday + weekends).
// Hand-count: starts Apr 30 (Thu), wd1=May 4 (skip Fri holiday + weekend), wd2=May 5,
// wd3=May 6, wd4=May 7, wd5=May 8 (Fri), wd6=May 11 (Mon), wd7=May 12,
// wd8=May 13, wd9=May 14 (Christi Himmelfahrt skipped) → wd9=May 15, wd10=May 18,
// wd11=May 19, wd12=May 20, wd13=May 21, wd14=May 22, wd15=May 25 (Pfingstmontag skipped) → wd15=May 26,
// wd16=May 27, wd17=May 28, wd18=May 29, wd19=Jun 1, wd20=Jun 2.
// So altAdj = 2026-06-02.
wantAlt := time.Date(2026, 6, 2, 0, 0, 0, 0, time.UTC)
if !altAdj.Equal(wantAlt) {
t.Fatalf("alt leg: got %s, want %s", altAdj, wantAlt)
}
wantBase := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
if !baseAdj.Equal(wantBase) {
t.Fatalf("base leg: got %s, want %s", baseAdj, wantBase)
}
// max(base, alt) = altAdj (Jun 2 > Jun 1) — working_days leg wins this case,
// matching the R.198 "whichever is longer" intent.
if !altAdj.After(baseAdj) {
t.Error("expected altAdj > baseAdj (working_days leg longer than 31d leg)")
}
}
// TestEventDeadlineService_Calculate_Parity is the LOAD-BEARING assertion
// for Phase 3 Slice 3 (t-paliad-184). For every distinct trigger_event_id
// in paliad.event_deadlines, it calls EventDeadlineService.Calculate (now
// delegating to FristenrechnerService.calculateByTriggerEvent) AND
// independently computes the same dates via the legacy applyDuration
// helper directly against event_deadlines. Any divergence — date,
// composite-flag, rule_codes — signals a Pipeline-C regression that
// "Was kommt nach…" users would see in production.
//
// Why this matters: design §3.C + §3.2 cutover-ordering invariant 1 says
// "additive schema lands first" and invariant 3 says "service rewrite
// before drops". Slice 3 is the first slice where the unified backend
// becomes the live serving path for event-driven deadlines. If parity
// breaks here, every downstream slice rests on a regressed foundation.
//
// Skipped when TEST_DATABASE_URL is unset, mirroring audit_service_test.go.
func TestEventDeadlineService_Calculate_Parity(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping live DB parity 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 := NewEventDeadlineService(pool, NewDeadlineCalculator(holidays), holidays, courts, fristen)
// Distinct trigger_event_id values for which we have at least one
// active deadline in event_deadlines. The Slice 1 / Slice 2 / Slice 3
// chain doesn't touch event_deadlines, so this set is stable.
var triggerIDs []int64
if err := pool.SelectContext(ctx, &triggerIDs,
`SELECT DISTINCT trigger_event_id
FROM paliad.event_deadlines
WHERE is_active = true
ORDER BY trigger_event_id`); err != nil {
t.Fatalf("list trigger ids: %v", err)
}
if len(triggerIDs) == 0 {
t.Fatal("no event_deadlines rows — pipeline C corpus missing")
}
// Reference date — arbitrary working day so weekend rollover noise is
// minimal. The parity test compares against an independently-computed
// expected value, so any date that exercises the calculator is fine.
triggerDateStr := "2026-01-15"
triggerDate, _ := time.Parse("2006-01-02", triggerDateStr)
country, regime, err := courts.CountryRegime("", CountryDE, RegimeUPC)
if err != nil {
t.Fatalf("default court regime: %v", err)
}
type srcRow struct {
ID int64 `db:"id"`
Title string `db:"title"`
TitleDE string `db:"title_de"`
DurationValue int `db:"duration_value"`
DurationUnit string `db:"duration_unit"`
Timing string `db:"timing"`
AltDurationValue *int `db:"alt_duration_value"`
AltDurationUnit *string `db:"alt_duration_unit"`
CombineOp *string `db:"combine_op"`
}
var totalChecked int
for _, tid := range triggerIDs {
resp, err := svc.Calculate(ctx, tid, triggerDateStr, "")
if err != nil {
t.Errorf("trigger=%d Calculate: %v", tid, err)
continue
}
var src []srcRow
if err := pool.SelectContext(ctx, &src,
`SELECT id, title, title_de, duration_value, duration_unit, timing,
alt_duration_value, alt_duration_unit, combine_op
FROM paliad.event_deadlines
WHERE trigger_event_id = $1 AND is_active = true
ORDER BY id`, tid); err != nil {
t.Fatalf("trigger=%d load source: %v", tid, err)
}
if len(resp.Deadlines) != len(src) {
t.Errorf("trigger=%d: got %d deadlines, want %d", tid, len(resp.Deadlines), len(src))
continue
}
// Sort both by ID — Calculate's source SELECT also ORDER BY id, so
// after we look up the source row for each result we can compare
// positionally. (The unified path returns rows in sequence_order =
// 1000 + ed.id which is identical ordering.)
sort.Slice(resp.Deadlines, func(i, j int) bool {
return resp.Deadlines[i].ID < resp.Deadlines[j].ID
})
for i, r := range resp.Deadlines {
s := src[i]
totalChecked++
if r.ID != s.ID {
t.Errorf("trigger=%d idx=%d: id=%d, want %d", tid, i, r.ID, s.ID)
}
if r.Title != s.Title {
t.Errorf("trigger=%d id=%d: title mismatch: %q vs %q", tid, s.ID, r.Title, s.Title)
}
if r.TitleDE != s.TitleDE {
t.Errorf("trigger=%d id=%d: titleDE mismatch: %q vs %q", tid, s.ID, r.TitleDE, s.TitleDE)
}
if r.DurationValue != s.DurationValue {
t.Errorf("trigger=%d id=%d: durationValue mismatch: %d vs %d",
tid, s.ID, r.DurationValue, s.DurationValue)
}
if r.DurationUnit != s.DurationUnit {
t.Errorf("trigger=%d id=%d: durationUnit mismatch: %q vs %q",
tid, s.ID, r.DurationUnit, s.DurationUnit)
}
if r.Timing != s.Timing {
t.Errorf("trigger=%d id=%d: timing mismatch: %q vs %q", tid, s.ID, r.Timing, s.Timing)
}
// Date parity: independently compute the expected DueDate
// using the legacy applyDuration on the source row. If the
// unified path diverges by even one day, this surfaces it.
_, expectedAdj, _, _ := applyDuration(triggerDate, s.DurationValue, s.DurationUnit, s.Timing, country, regime, holidays)
if s.CombineOp != nil && s.AltDurationValue != nil && s.AltDurationUnit != nil {
_, altAdj, _, _ := applyDuration(triggerDate, *s.AltDurationValue, *s.AltDurationUnit, s.Timing, country, regime, holidays)
switch *s.CombineOp {
case "max":
if altAdj.After(expectedAdj) {
expectedAdj = altAdj
}
case "min":
if altAdj.Before(expectedAdj) {
expectedAdj = altAdj
}
}
}
gotAdj, perr := time.Parse("2006-01-02", r.DueDate)
if perr != nil {
t.Errorf("trigger=%d id=%d: parse dueDate %q: %v", tid, s.ID, r.DueDate, perr)
continue
}
if !gotAdj.Equal(expectedAdj) {
t.Errorf("trigger=%d id=%d (%q): dueDate=%s, want %s — Pipeline-C parity broken",
tid, s.ID, s.Title, r.DueDate, expectedAdj.Format("2006-01-02"))
}
// Composite flag parity.
wantComposite := s.CombineOp != nil && s.AltDurationValue != nil && s.AltDurationUnit != nil
if r.IsComposite != wantComposite {
t.Errorf("trigger=%d id=%d: isComposite=%v, want %v",
tid, s.ID, r.IsComposite, wantComposite)
}
}
}
// Final tally — at least the 77 active rows must have been checked.
if totalChecked < 77 {
t.Errorf("checked only %d Pipeline-C rows (want >=77) — parity sweep incomplete", totalChecked)
}
}