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