Files
paliad/internal/services/deadline_calculator_test.go
m d72990ad1b feat(t-paliad-122): country+regime aware HolidayService + CourtService
Holiday struct gains Country (ISO-3166) + Regime ('UPC' | 'EPO' | "")
fields. AppliesTo(country, regime) is the matching rule the new lookup
methods filter through: a row matches when its Country equals the
court's country OR its Regime equals the court's regime. UPC LD München
(DE+UPC) sees DE federal + UPC vacations; LG München (DE+"") sees only
DE federal; UPC LD Paris (FR+UPC) sees FR + UPC. germanFederalHolidays
fallback now country-tagged 'DE' so the per-country filter applies it
only to DE-jurisdictional callers.

Public service methods (IsHoliday, IsNonWorkingDay, AdjustForNonWorking
Days, AdjustForNonWorkingDaysWithReason, findVacationBlock) all take
(country, regime). Cache stays year-keyed — single DB hit per year, all
courts touching that year share it.

New CourtService loads paliad.courts once + answers Lookup(id),
CountryRegime(id, defaultCountry, defaultRegime), All(), ByCourtType(t).
FristenrechnerService.CalcOptions / CalcRuleParams gain CourtID;
EventDeadlineService.Calculate gains courtID. When courtID is empty,
DefaultsForJurisdiction maps the proceeding's existing jurisdiction
column to a sensible (country, regime) default — UPC proceedings get
(DE, UPC), everything else gets DE-only — preserving today's behaviour
for callers that don't yet send a court.

Tests: new TestAppliesTo_CountryRegimeFilter + TestAppliesTo_Rules
cover the cross-product of (DE court / UPC LD München / UPC LD Paris /
LG München) × (DE federal / UPC vacation / FR holiday). Existing tests
threaded through with ('DE', 'UPC') to preserve behaviour they were
written to lock.
2026-05-06 12:47:12 +02:00

172 lines
5.9 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")
}
}
func TestCalculateEndDate_BeforeTiming(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 - 1 month = 2026-03-15 (Sunday).
// Adjust: Sunday → Monday 2026-03-16.
in := time.Date(2026, 4, 15, 0, 0, 0, 0, time.UTC)
adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC")
want := time.Date(2026, 3, 16, 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", Code: ptr("inf.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 != "inf.sod" {
t.Errorf("rule code: got %q, want inf.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.
}