Files
paliad/internal/services/event_deadline_service_test.go
mAi 29a6b58747 refactor(t-paliad-199): Slice 9 follow-up A — drop legacy event_deadlines tables
EventDeadlineService.Calculate now reads source rows from
paliad.deadline_rules directly (WHERE trigger_event_id IS NOT NULL),
joining via UUID instead of title_de string. The legacy SELECTs against
paliad.event_deadlines + paliad.event_deadline_rule_codes are gone.

Migration 092:
- Snapshots both legacy tables into _pre_092 audit anchors.
- Adds paliad.deadline_rules.rule_codes text[] and backfills the 72
  multi-code citations from event_deadline_rule_codes via the
  sequence_order = 1000 + ed.id convention from mig 085 (70 of 77
  Pipeline-C deadlines carry codes; 7 are codeless).
- Hard assertion ties source-junction-row count to backfilled
  text[]-element count — any sequence_order mismatch aborts the drop.
- Drops the mig 086 read-only trigger (orphan once event_deadlines
  goes away).
- Drops paliad.event_deadlines + paliad.event_deadline_rule_codes.
- Final assertion: >=77 active deadline_rules with trigger_event_id
  NOT NULL — Slice 3 corpus must not have collapsed.
- audit_reason wrapper at top so the deadline_rules UPDATE row-trigger
  records the reason in deadline_rule_audit.

Verified via BEGIN..ROLLBACK against the live paliad DB: 72 codes
backfilled into 70 rule_codes arrays, multi-code rules (RoP.029.a +
RoP.030 for ed_id=6) preserve their ordering, composite rules
(combine_op=max) remain intact, both tables drop cleanly, all
assertions pass.

Parity test rebound to deadline_rules — independent computation still
re-runs applyDuration against raw column values for date/composite
parity. EventDeadlineResult.ID stays int64 via the sequence_order -
1000 convention so the public /api/tools/event-deadlines wire shape
is unchanged.
2026-05-16 01:17:23 +02:00

327 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 the Pipeline-C corpus, it calls EventDeadlineService.Calculate (now
// fully delegating to FristenrechnerService.calculateByTriggerEvent) AND
// independently computes the same dates via the package-level
// applyDuration helper against the same deadline_rules source rows. Any
// divergence — date, composite-flag, rule_codes — signals a Pipeline-C
// regression that "Was kommt nach…" users would see in production.
//
// Phase 3 Slice 9 follow-up A (t-paliad-199): mig 092 dropped
// paliad.event_deadlines + paliad.event_deadline_rule_codes. The test
// source query now reads from paliad.deadline_rules WHERE
// trigger_event_id IS NOT NULL — the unified row set the service
// reads. The independent computation is still meaningful: it bypasses
// FristenrechnerService entirely and re-runs the package-level
// applyDuration math against the raw column values, so any future
// regression in the calculator's wrapping logic surfaces here.
//
// Field mapping (post-mig-092): name_en → Title, name → TitleDE,
// (sequence_order - 1000) → ID (legacy event_deadlines.id semantic via
// mig 085's sequence_order = 1000 + ed.id convention).
//
// 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 Pipeline-C rule. Mig 085 moved 77 active rows from
// event_deadlines into deadline_rules with trigger_event_id IS NOT
// NULL, so the set is stable across Slice 9 + follow-up A.
var triggerIDs []int64
if err := pool.SelectContext(ctx, &triggerIDs,
`SELECT DISTINCT trigger_event_id
FROM paliad.deadline_rules
WHERE trigger_event_id IS NOT NULL AND is_active = true
ORDER BY trigger_event_id`); err != nil {
t.Fatalf("list trigger ids: %v", err)
}
if len(triggerIDs) == 0 {
t.Fatal("no Pipeline-C rules — 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)
}
// Source-row shape mirrors EventDeadlineResult's columns so the
// comparison is direct. ID derives from sequence_order via the
// mig 085 convention; the post-mig-092 service does the same.
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 (sequence_order - 1000) AS id,
name_en AS title,
name AS title_de,
duration_value, duration_unit,
COALESCE(timing, 'after') AS timing,
alt_duration_value, alt_duration_unit, combine_op
FROM paliad.deadline_rules
WHERE trigger_event_id = $1 AND is_active = true
ORDER BY sequence_order`, 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 — the source SELECT ORDER BYs sequence_order
// and we derive ID = sequence_order - 1000, so positional
// comparison after the sort is exact.
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)
}
}