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