Implements three Tier 3 primitives from curie's bulletproof completeness
audit (docs/research-deadlines-completeness-2026-05-25.md §10 T3.1, T3.2,
T3.5), per m's 2026-05-25 15:29 steer to build the full primitives
instead of documenting workarounds.
Primitive 1 — duration_unit='working_days':
Calculator walks day-by-day skipping weekends + court holidays via
HolidayService.IsNonWorkingDay. Event day is not counted; result is
always a working day for the (country, regime). Unlocks T1.8/T1.9
modeling and the R.198 / R.213 alt leg.
Primitive 2 — combine_op='max' (and 'min'):
When alt_duration_value + alt_duration_unit + combine_op are set, the
calculator evaluates both legs and picks the later (max) or earlier
(min) of the two adjusted end dates. The DB already had two rules
shaped this way ('31d OR 20wd, whichever is longer' — R.198 / R.213);
the calculator was silently dropping the alt leg.
Primitive 5 — timing='before' backward snap-to-working-day:
For backward rules (R.109.1: 1 month before oral hearing; R.109.4:
2 weeks before) the calculator now snaps to the PRECEDING working day
when the computed cut-off lands on a weekend/holiday. Forward snap
(the prior behavior) would push the cut-off past the statutory limit
and miss the deadline. Adds HolidayService.AdjustForNonWorkingDays-
Backward as the symmetric counterpart of AdjustForNonWorkingDays.
Migration 128 — DB schema:
Adds CHECK constraints on deadline_rules.duration_unit and
alt_duration_unit pinning the allowed set to days/weeks/months/
working_days. Live data audited and passes (no rows excluded).
Tests (12 new + 1 flipped):
- 5 working_days cases: forward over weekend, 20wd anchored on Fri,
across Karfreitag/Ostermontag, across year boundary, backward
from Friday, anchored on Saturday.
- 2 backward snap cases: Sun → preceding Fri; cluster Sun → Sat →
Karfreitag → Thu.
- 4 combine_op cases: max with primary winning, max with alt winning
over Christmas+Neujahr cluster, min with primary winning, NULL-alt
short-circuit.
- TestCalculateEndDate_BeforeTiming renamed and flipped from forward
(Sun → Mon, the prior wrong behavior) to backward (Sun → Fri).
No regression on existing rules: every pre-existing days/weeks/months
'after' rule still computes the same date. Frontend build + full
go test ./internal/... clean.
Slot 128 assigned per next-available convention (mig 127 = Wave 0
Tier-0 fixes, mig 128 = Wave 2 Tier-3 Slice A primitives).
489 lines
18 KiB
Go
489 lines
18 KiB
Go
package services
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"mgit.msbls.de/m/paliad/internal/models"
|
|
)
|
|
|
|
func ptr[T any](v T) *T { return &v }
|
|
|
|
func TestCalculateEndDate_Days(t *testing.T) {
|
|
holidays := NewHolidayService(nil)
|
|
calc := NewDeadlineCalculator(holidays)
|
|
|
|
rule := models.DeadlineRule{
|
|
ID: uuid.New(),
|
|
Name: "Test rule",
|
|
DurationValue: 10,
|
|
DurationUnit: "days",
|
|
Timing: ptr("after"),
|
|
}
|
|
// 2026-01-13 (Tue) + 10 days = 2026-01-23 (Fri) — no adjustment.
|
|
in := time.Date(2026, 1, 13, 0, 0, 0, 0, time.UTC)
|
|
adjusted, original, wasAdjusted := calc.CalculateEndDate(in, rule, "DE", "UPC")
|
|
want := time.Date(2026, 1, 23, 0, 0, 0, 0, time.UTC)
|
|
|
|
if !adjusted.Equal(want) {
|
|
t.Errorf("adjusted: got %s, want %s", adjusted, want)
|
|
}
|
|
if !original.Equal(want) {
|
|
t.Errorf("original: got %s, want %s", original, want)
|
|
}
|
|
if wasAdjusted {
|
|
t.Error("did not expect adjustment for working day")
|
|
}
|
|
}
|
|
|
|
func TestCalculateEndDate_Months_HolidayAdjust(t *testing.T) {
|
|
holidays := NewHolidayService(nil)
|
|
calc := NewDeadlineCalculator(holidays)
|
|
|
|
rule := models.DeadlineRule{
|
|
ID: uuid.New(),
|
|
Name: "3-month rule",
|
|
DurationValue: 3,
|
|
DurationUnit: "months",
|
|
Timing: ptr("after"),
|
|
}
|
|
// 2026-01-01 (Neujahr) + 3 months = 2026-04-01.
|
|
// 2026-04-01 = Wednesday → working day, no adjust.
|
|
in := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
adjusted, original, _ := calc.CalculateEndDate(in, rule, "DE", "UPC")
|
|
wantOrig := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)
|
|
if !original.Equal(wantOrig) {
|
|
t.Errorf("original: got %s, want %s", original, wantOrig)
|
|
}
|
|
if !adjusted.Equal(wantOrig) {
|
|
t.Errorf("adjusted: got %s, want %s", adjusted, wantOrig)
|
|
}
|
|
}
|
|
|
|
func TestCalculateEndDate_Weeks_LandsOnHoliday(t *testing.T) {
|
|
holidays := NewHolidayService(nil)
|
|
calc := NewDeadlineCalculator(holidays)
|
|
|
|
rule := models.DeadlineRule{
|
|
ID: uuid.New(),
|
|
Name: "2-week rule",
|
|
DurationValue: 2,
|
|
DurationUnit: "weeks",
|
|
Timing: ptr("after"),
|
|
}
|
|
// Land on Karfreitag 2026 (2026-04-03, Friday).
|
|
// Trigger: 2026-03-20 (Friday). +2 weeks = 2026-04-03 = Karfreitag.
|
|
// Adjust: skip Karfreitag (Fri), Sat, Sun, Ostermontag (Mon 04-06), to
|
|
// Tuesday 2026-04-07.
|
|
in := time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC)
|
|
adjusted, original, wasAdjusted := calc.CalculateEndDate(in, rule, "DE", "UPC")
|
|
wantOrig := time.Date(2026, 4, 3, 0, 0, 0, 0, time.UTC)
|
|
wantAdj := time.Date(2026, 4, 7, 0, 0, 0, 0, time.UTC)
|
|
|
|
if !original.Equal(wantOrig) {
|
|
t.Errorf("original: got %s, want %s", original, wantOrig)
|
|
}
|
|
if !adjusted.Equal(wantAdj) {
|
|
t.Errorf("adjusted: got %s, want %s", adjusted, wantAdj)
|
|
}
|
|
if !wasAdjusted {
|
|
t.Error("expected wasAdjusted=true")
|
|
}
|
|
}
|
|
|
|
// TestCalculateEndDate_BeforeTiming_SnapsBackward — Tier 3 Primitive 5
|
|
// (m/paliad#103 Slice A). For timing='before' rules (R.109.1 / R.109.4
|
|
// "no later than X before the oral hearing"), a computed cut-off that
|
|
// lands on a weekend / holiday must snap *backward* to the preceding
|
|
// working day. Forward snap would push the cut-off past the statutory
|
|
// limit and miss the deadline. See
|
|
// docs/research-deadlines-completeness-2026-05-25.md §10 T3.5.
|
|
func TestCalculateEndDate_BeforeTiming_SnapsBackward(t *testing.T) {
|
|
holidays := NewHolidayService(nil)
|
|
calc := NewDeadlineCalculator(holidays)
|
|
|
|
rule := models.DeadlineRule{
|
|
ID: uuid.New(),
|
|
Name: "1-month before",
|
|
DurationValue: 1,
|
|
DurationUnit: "months",
|
|
Timing: ptr("before"),
|
|
}
|
|
// "before" subtracts: 2026-04-15 (Wed) - 1 month = 2026-03-15 (Sunday).
|
|
// Backward snap: Sunday → Friday 2026-03-13 (Karfreitag is later
|
|
// in 2026, so no extra holiday in this window).
|
|
in := time.Date(2026, 4, 15, 0, 0, 0, 0, time.UTC)
|
|
adjusted, original, wasAdjusted := calc.CalculateEndDate(in, rule, "DE", "UPC")
|
|
wantOrig := time.Date(2026, 3, 15, 0, 0, 0, 0, time.UTC)
|
|
wantAdj := time.Date(2026, 3, 13, 0, 0, 0, 0, time.UTC)
|
|
if !original.Equal(wantOrig) {
|
|
t.Errorf("original: got %s, want %s", original, wantOrig)
|
|
}
|
|
if !adjusted.Equal(wantAdj) {
|
|
t.Errorf("adjusted: got %s, want %s", adjusted, wantAdj)
|
|
}
|
|
if !wasAdjusted {
|
|
t.Error("expected wasAdjusted=true (Sun → preceding Fri)")
|
|
}
|
|
}
|
|
|
|
// Tier 3 Primitive 5 — backward snap across Karfreitag / Ostermontag.
|
|
// 2026 Ostern: Karfreitag = 2026-04-03 (Fri), Ostermontag = 2026-04-06 (Mon).
|
|
// Anchor Tue 2026-05-05 minus 1 month = Sun 2026-04-05 → backward through
|
|
// Sat → Karfreitag → Thu 2026-04-02.
|
|
func TestCalculateEndDate_BeforeTiming_BackwardSkipsHolidayCluster(t *testing.T) {
|
|
holidays := NewHolidayService(nil)
|
|
calc := NewDeadlineCalculator(holidays)
|
|
|
|
rule := models.DeadlineRule{
|
|
ID: uuid.New(),
|
|
Name: "1-month before, Ostern cluster",
|
|
DurationValue: 1,
|
|
DurationUnit: "months",
|
|
Timing: ptr("before"),
|
|
}
|
|
in := time.Date(2026, 5, 5, 0, 0, 0, 0, time.UTC)
|
|
adjusted, _, wasAdjusted := calc.CalculateEndDate(in, rule, "DE", "UPC")
|
|
want := time.Date(2026, 4, 2, 0, 0, 0, 0, time.UTC)
|
|
if !adjusted.Equal(want) {
|
|
t.Errorf("adjusted: got %s, want %s", adjusted, want)
|
|
}
|
|
if !wasAdjusted {
|
|
t.Error("expected wasAdjusted=true (Sun→Karfreitag→Thu)")
|
|
}
|
|
}
|
|
|
|
// Tier 3 Primitive 1 — working_days arithmetic forward over a weekend.
|
|
// Anchor Mon 2026-01-12 + 5 working days = Tue 13 (1), Wed 14 (2),
|
|
// Thu 15 (3), Fri 16 (4), Mon 19 (5). Result = Mon 2026-01-19.
|
|
func TestCalculateEndDate_WorkingDays_ForwardSkipsWeekend(t *testing.T) {
|
|
holidays := NewHolidayService(nil)
|
|
calc := NewDeadlineCalculator(holidays)
|
|
|
|
rule := models.DeadlineRule{
|
|
ID: uuid.New(),
|
|
Name: "5 working days",
|
|
DurationValue: 5,
|
|
DurationUnit: "working_days",
|
|
Timing: ptr("after"),
|
|
}
|
|
in := time.Date(2026, 1, 12, 0, 0, 0, 0, time.UTC)
|
|
adjusted, original, wasAdjusted := calc.CalculateEndDate(in, rule, "DE", "UPC")
|
|
want := time.Date(2026, 1, 19, 0, 0, 0, 0, time.UTC)
|
|
if !adjusted.Equal(want) {
|
|
t.Errorf("adjusted: got %s, want %s", adjusted, want)
|
|
}
|
|
// working_days arithmetic lands on a working day by construction, so the
|
|
// "snap" reports no adjustment and original == adjusted.
|
|
if !original.Equal(want) {
|
|
t.Errorf("original: got %s, want %s", original, want)
|
|
}
|
|
if wasAdjusted {
|
|
t.Error("working_days result should not report a snap adjustment")
|
|
}
|
|
}
|
|
|
|
// Tier 3 Primitive 1 — working_days arithmetic with anchor on Friday;
|
|
// 20 working days lands on the Friday four weeks later. Anchor Fri
|
|
// 2026-01-09 → +20wd → Fri 2026-02-06. No DE federal holiday in
|
|
// window. This exercises the R.198 / R.213 "20 working days" leg.
|
|
func TestCalculateEndDate_WorkingDays_TwentyDays(t *testing.T) {
|
|
holidays := NewHolidayService(nil)
|
|
calc := NewDeadlineCalculator(holidays)
|
|
|
|
rule := models.DeadlineRule{
|
|
ID: uuid.New(),
|
|
Name: "20 working days",
|
|
DurationValue: 20,
|
|
DurationUnit: "working_days",
|
|
Timing: ptr("after"),
|
|
}
|
|
in := time.Date(2026, 1, 9, 0, 0, 0, 0, time.UTC)
|
|
adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC")
|
|
want := time.Date(2026, 2, 6, 0, 0, 0, 0, time.UTC)
|
|
if !adjusted.Equal(want) {
|
|
t.Errorf("adjusted: got %s, want %s", adjusted, want)
|
|
}
|
|
}
|
|
|
|
// Tier 3 Primitive 1 — working_days across Karfreitag/Ostermontag. Anchor
|
|
// Thu 2026-04-02 + 3 working days: skip Karfreitag (Fri 04-03), weekend,
|
|
// Ostermontag (Mon 04-06). Walk: Tue 04-07 (1), Wed 04-08 (2), Thu 04-09
|
|
// (3). Result = Thu 2026-04-09.
|
|
func TestCalculateEndDate_WorkingDays_AcrossEasterCluster(t *testing.T) {
|
|
holidays := NewHolidayService(nil)
|
|
calc := NewDeadlineCalculator(holidays)
|
|
|
|
rule := models.DeadlineRule{
|
|
ID: uuid.New(),
|
|
Name: "3 working days over Ostern",
|
|
DurationValue: 3,
|
|
DurationUnit: "working_days",
|
|
Timing: ptr("after"),
|
|
}
|
|
in := time.Date(2026, 4, 2, 0, 0, 0, 0, time.UTC)
|
|
adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC")
|
|
want := time.Date(2026, 4, 9, 0, 0, 0, 0, time.UTC)
|
|
if !adjusted.Equal(want) {
|
|
t.Errorf("adjusted: got %s, want %s", adjusted, want)
|
|
}
|
|
}
|
|
|
|
// Tier 3 Primitive 1 — working_days across year boundary. Anchor Mon
|
|
// 2025-12-29 + 5 working days. Calendar: Tue 30 (1), Wed 31 (2),
|
|
// Thu 2026-01-01 = Neujahr (skip), Fri 2026-01-02 (3), Mon 05 (4),
|
|
// Tue 06 (5). Result = Tue 2026-01-06.
|
|
func TestCalculateEndDate_WorkingDays_AcrossYearBoundary(t *testing.T) {
|
|
holidays := NewHolidayService(nil)
|
|
calc := NewDeadlineCalculator(holidays)
|
|
|
|
rule := models.DeadlineRule{
|
|
ID: uuid.New(),
|
|
Name: "5 working days over year-end",
|
|
DurationValue: 5,
|
|
DurationUnit: "working_days",
|
|
Timing: ptr("after"),
|
|
}
|
|
in := time.Date(2025, 12, 29, 0, 0, 0, 0, time.UTC)
|
|
adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC")
|
|
want := time.Date(2026, 1, 6, 0, 0, 0, 0, time.UTC)
|
|
if !adjusted.Equal(want) {
|
|
t.Errorf("adjusted: got %s, want %s", adjusted, want)
|
|
}
|
|
}
|
|
|
|
// Tier 3 Primitive 1 — working_days backward (timing='before'). Anchor
|
|
// Fri 2026-04-17 - 5 working days: Thu 16 (1), Wed 15 (2), Tue 14 (3),
|
|
// Mon 13 (4), Fri 10 (5 — Mon 13 - 3 days skipping Sun/Sat). Result =
|
|
// Fri 2026-04-10.
|
|
func TestCalculateEndDate_WorkingDays_BackwardSkipsWeekend(t *testing.T) {
|
|
holidays := NewHolidayService(nil)
|
|
calc := NewDeadlineCalculator(holidays)
|
|
|
|
rule := models.DeadlineRule{
|
|
ID: uuid.New(),
|
|
Name: "5 working days before",
|
|
DurationValue: 5,
|
|
DurationUnit: "working_days",
|
|
Timing: ptr("before"),
|
|
}
|
|
in := time.Date(2026, 4, 17, 0, 0, 0, 0, time.UTC)
|
|
adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC")
|
|
want := time.Date(2026, 4, 10, 0, 0, 0, 0, time.UTC)
|
|
if !adjusted.Equal(want) {
|
|
t.Errorf("adjusted: got %s, want %s", adjusted, want)
|
|
}
|
|
}
|
|
|
|
// Tier 3 Primitive 1 — working_days anchored on a Saturday (rare but
|
|
// must not loop). +3 working days from Sat 2026-01-10: Mon 12 (1), Tue
|
|
// 13 (2), Wed 14 (3). Result = Wed 2026-01-14.
|
|
func TestCalculateEndDate_WorkingDays_AnchorOnWeekend(t *testing.T) {
|
|
holidays := NewHolidayService(nil)
|
|
calc := NewDeadlineCalculator(holidays)
|
|
|
|
rule := models.DeadlineRule{
|
|
ID: uuid.New(),
|
|
Name: "3 working days from Saturday",
|
|
DurationValue: 3,
|
|
DurationUnit: "working_days",
|
|
Timing: ptr("after"),
|
|
}
|
|
in := time.Date(2026, 1, 10, 0, 0, 0, 0, time.UTC)
|
|
adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC")
|
|
want := time.Date(2026, 1, 14, 0, 0, 0, 0, time.UTC)
|
|
if !adjusted.Equal(want) {
|
|
t.Errorf("adjusted: got %s, want %s", adjusted, want)
|
|
}
|
|
}
|
|
|
|
// Tier 3 Primitive 2 — combine_op='max' picks the LATER of two adjusted
|
|
// end-dates. Matches UPC RoP R.198 / R.213 "31 calendar days OR 20
|
|
// working days, whichever is longer". Anchor Mon 2026-01-12.
|
|
// - Primary: 31 cal days → Sun 2026-02-12... wait, Mon Jan 12 + 31 =
|
|
// Thu 2026-02-12 (verify: Jan has 31 days; 12 + 31 = day-43 of year
|
|
// = Feb 12). Feb 12 2026 is Thursday → no snap, +31d.
|
|
// - Alt: 20 working_days → Mon Jan 12 + 20wd: Tue 13 (1) ... walk
|
|
// gives Mon 2026-02-09 (20 business days later, no DE holiday).
|
|
//
|
|
// max(Feb 12 Thu, Feb 09 Mon) = Feb 12 → primary wins.
|
|
func TestCalculateEndDate_CombineMax_PrimaryWins(t *testing.T) {
|
|
holidays := NewHolidayService(nil)
|
|
calc := NewDeadlineCalculator(holidays)
|
|
|
|
rule := models.DeadlineRule{
|
|
ID: uuid.New(),
|
|
Name: "31d OR 20wd, max",
|
|
DurationValue: 31,
|
|
DurationUnit: "days",
|
|
Timing: ptr("after"),
|
|
AltDurationValue: ptr(20),
|
|
AltDurationUnit: ptr("working_days"),
|
|
CombineOp: ptr("max"),
|
|
}
|
|
in := time.Date(2026, 1, 12, 0, 0, 0, 0, time.UTC)
|
|
adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC")
|
|
want := time.Date(2026, 2, 12, 0, 0, 0, 0, time.UTC)
|
|
if !adjusted.Equal(want) {
|
|
t.Errorf("adjusted: got %s, want %s", adjusted, want)
|
|
}
|
|
}
|
|
|
|
// Tier 3 Primitive 2 — combine_op='max', alt wins. Anchor that makes the
|
|
// 20-working-days leg longer than the 31-cal-day leg. Anchor Fri
|
|
// 2026-01-09: +31 cal days = Mon 2026-02-09 (calendar weekday, no snap);
|
|
// +20 working_days = Fri 2026-02-06 ... actually let's pick an anchor
|
|
// where the working-days side overshoots. Anchor over a long-weekend
|
|
// cluster: Wed 2026-12-23, +31cal = Sat 2027-01-23 → forward-snap to Mon
|
|
// 2027-01-25 (DE has no holiday that day). +20wd = walk skipping Heilig
|
|
// Abend, Christmas, Neujahr, weekends. Pick simpler: anchor where 31cal
|
|
// + snap ≈ 20wd + cluster.
|
|
//
|
|
// Concrete: anchor Mon 2026-01-12, mock the 31d leg landing on Sun
|
|
// 2026-02-15 (no — Jan 12 + 34 days = Feb 15, not 31). For deterministic
|
|
// "alt wins", we use a configurable anchor and check the relative order
|
|
// instead.
|
|
func TestCalculateEndDate_CombineMax_AltWins(t *testing.T) {
|
|
holidays := NewHolidayService(nil)
|
|
calc := NewDeadlineCalculator(holidays)
|
|
|
|
// Anchor Thu 2026-12-24 (Heilig Abend is not a DE federal holiday;
|
|
// holiday service only has Neujahr/Easter/.../Weihnachtstag — Dec
|
|
// 24 is a working day here). +14 calendar days = Thu 2027-01-07.
|
|
// +20 working_days walks Fri 12-25 (1. Weihnachtstag — skip), ...
|
|
// arrives much later. Use 14 days vs 20 working_days to make alt
|
|
// reliably win on this stretch.
|
|
rule := models.DeadlineRule{
|
|
ID: uuid.New(),
|
|
Name: "14d OR 20wd, max",
|
|
DurationValue: 14,
|
|
DurationUnit: "days",
|
|
Timing: ptr("after"),
|
|
AltDurationValue: ptr(20),
|
|
AltDurationUnit: ptr("working_days"),
|
|
CombineOp: ptr("max"),
|
|
}
|
|
in := time.Date(2026, 12, 24, 0, 0, 0, 0, time.UTC)
|
|
adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC")
|
|
// Primary 14 cal days: Dec 24 (Thu) + 14 = Jan 7 2027 (Thu), working
|
|
// day → no snap. Alt 20 working_days walks past Christmas + Neujahr:
|
|
// Fri 12-25 (1.W) skip, Sat/Sun 12-26/27 skip (Sat counts as
|
|
// non-working; 2.W on 26 also skips), Mon 12-28 (1), Tue 12-29 (2),
|
|
// Wed 12-30 (3), Thu 12-31 (4), Fri 01-01-2027 Neujahr skip, Mon
|
|
// 01-04 (5), Tue 01-05 (6), Wed 01-06 (7), Thu 01-07 (8), Fri 01-08
|
|
// (9), Mon 01-11 (10), Tue 01-12 (11), Wed 01-13 (12), Thu 01-14
|
|
// (13), Fri 01-15 (14), Mon 01-18 (15), Tue 01-19 (16), Wed 01-20
|
|
// (17), Thu 01-21 (18), Fri 01-22 (19), Mon 01-25 (20). Result =
|
|
// Mon 2027-01-25. After max(Jan 7, Jan 25) → Jan 25.
|
|
want := time.Date(2027, 1, 25, 0, 0, 0, 0, time.UTC)
|
|
if !adjusted.Equal(want) {
|
|
t.Errorf("adjusted: got %s, want %s", adjusted, want)
|
|
}
|
|
}
|
|
|
|
// Tier 3 Primitive 2 — combine_op='min' picks the EARLIER end-date.
|
|
// Same shape as the max test but inverted. Same Dec 24 2026 anchor,
|
|
// 14d vs 20wd: min = Jan 7 2027 (the primary leg).
|
|
func TestCalculateEndDate_CombineMin_PrimaryWins(t *testing.T) {
|
|
holidays := NewHolidayService(nil)
|
|
calc := NewDeadlineCalculator(holidays)
|
|
|
|
rule := models.DeadlineRule{
|
|
ID: uuid.New(),
|
|
Name: "14d OR 20wd, min",
|
|
DurationValue: 14,
|
|
DurationUnit: "days",
|
|
Timing: ptr("after"),
|
|
AltDurationValue: ptr(20),
|
|
AltDurationUnit: ptr("working_days"),
|
|
CombineOp: ptr("min"),
|
|
}
|
|
in := time.Date(2026, 12, 24, 0, 0, 0, 0, time.UTC)
|
|
adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC")
|
|
want := time.Date(2027, 1, 7, 0, 0, 0, 0, time.UTC)
|
|
if !adjusted.Equal(want) {
|
|
t.Errorf("adjusted: got %s, want %s", adjusted, want)
|
|
}
|
|
}
|
|
|
|
// Tier 3 Primitive 2 — combine_op with NULL alt fields short-circuits to
|
|
// the primary-only result (defensive: drift in seed data shouldn't crash
|
|
// the calculator). Same as the basic days test but with combine_op set
|
|
// and alt fields nil.
|
|
func TestCalculateEndDate_CombineOp_AltNil_FallsBackToPrimary(t *testing.T) {
|
|
holidays := NewHolidayService(nil)
|
|
calc := NewDeadlineCalculator(holidays)
|
|
|
|
rule := models.DeadlineRule{
|
|
ID: uuid.New(),
|
|
Name: "Primary only, stray combine_op",
|
|
DurationValue: 10,
|
|
DurationUnit: "days",
|
|
Timing: ptr("after"),
|
|
CombineOp: ptr("max"),
|
|
}
|
|
in := time.Date(2026, 1, 13, 0, 0, 0, 0, time.UTC)
|
|
adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC")
|
|
want := time.Date(2026, 1, 23, 0, 0, 0, 0, time.UTC)
|
|
if !adjusted.Equal(want) {
|
|
t.Errorf("adjusted: got %s, want %s", adjusted, want)
|
|
}
|
|
}
|
|
|
|
func TestCalculateFromRules_BatchAndZeroDuration(t *testing.T) {
|
|
holidays := NewHolidayService(nil)
|
|
calc := NewDeadlineCalculator(holidays)
|
|
|
|
rules := []models.DeadlineRule{
|
|
{ID: uuid.New(), Name: "Filing", DurationValue: 0, DurationUnit: "months"},
|
|
{ID: uuid.New(), Name: "Defence", SubmissionCode: ptr("upc.inf.cfi.sod"), DurationValue: 3, DurationUnit: "months", Timing: ptr("after")},
|
|
}
|
|
in := time.Date(2026, 1, 13, 0, 0, 0, 0, time.UTC)
|
|
results := calc.CalculateFromRules(in, rules, "DE", "UPC")
|
|
|
|
if len(results) != 2 {
|
|
t.Fatalf("got %d results, want 2", len(results))
|
|
}
|
|
// Zero-duration rule returns the trigger date unchanged.
|
|
if results[0].DueDate != "2026-01-13" {
|
|
t.Errorf("zero-duration rule: got %s, want 2026-01-13", results[0].DueDate)
|
|
}
|
|
// 3-month rule → 2026-04-13 (Monday, working).
|
|
if results[1].DueDate != "2026-04-13" {
|
|
t.Errorf("3-month rule: got %s, want 2026-04-13", results[1].DueDate)
|
|
}
|
|
if results[1].RuleCode != "upc.inf.cfi.sod" {
|
|
t.Errorf("rule code: got %q, want upc.inf.cfi.sod", results[1].RuleCode)
|
|
}
|
|
}
|
|
|
|
// PR-3 audit fix: AdjustForNonWorkingDays must walk past the full UPC summer
|
|
// vacation (~33 weekdays) plus the flanking weekend without bailing on the
|
|
// 30-iteration cap. Pre-PR-3 a SoD on 2026-04-30 (3mo from trigger) would
|
|
// adjust to Sat 2026-08-29 (mid-walk, off-by-31). Correct landing is Mon
|
|
// 2026-08-31 (UPC vacation ends Aug 28 Fri; weekend skipped).
|
|
func TestAdjustForNonWorkingDays_WalksPastSummerVacation(t *testing.T) {
|
|
holidays := NewHolidayService(nil) // pure German federal holidays — no UPC vacation
|
|
// To reproduce the production case (which has UPC summer vacation seeded
|
|
// in paliad.holidays), we shim the holiday service with a custom config
|
|
// matching migration 010's UPC summer vacation.
|
|
// Without the seed, a Thu 2026-07-30 + 0 days adjustment is a no-op
|
|
// (working day), which doesn't exercise the cap. Skip if running without
|
|
// the production seed.
|
|
in := time.Date(2026, 7, 30, 0, 0, 0, 0, time.UTC)
|
|
if holidays.IsNonWorkingDay(in, "DE", "UPC") {
|
|
t.Skip("Thu 2026-07-30 unexpectedly flagged as non-working without UPC seed")
|
|
}
|
|
// Sanity: with no UPC vacation, Thu 2026-07-30 + 0 → unchanged.
|
|
adjusted, _, wasAdjusted := holidays.AdjustForNonWorkingDays(in, "DE", "UPC")
|
|
if wasAdjusted {
|
|
t.Errorf("expected no adjustment without UPC seed; got %s", adjusted)
|
|
}
|
|
// The behaviour we actually rely on (cap=60) is only observable against
|
|
// the seeded paliad.holidays. The production smoke test for t-paliad-086
|
|
// PR-3 ("SoD 3mo from 2026-04-30 → adjusted Mon 2026-08-31, not Sat
|
|
// 2026-08-29") locks the live behaviour.
|
|
}
|