Files
paliad/internal/services/holidays_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

389 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package services
import (
"sync"
"testing"
"time"
)
func TestCalculateEasterSunday(t *testing.T) {
cases := []struct {
year int
wantMonth int
wantDay int
}{
{2024, 3, 31},
{2025, 4, 20},
{2026, 4, 5},
{2027, 3, 28},
{2030, 4, 21},
}
for _, c := range cases {
gotMonth, gotDay := CalculateEasterSunday(c.year)
if gotMonth != c.wantMonth || gotDay != c.wantDay {
t.Errorf("Easter %d: got %d-%d, want %d-%d",
c.year, gotMonth, gotDay, c.wantMonth, c.wantDay)
}
}
}
func TestGermanFederalHolidays(t *testing.T) {
holidays := germanFederalHolidays(2026)
if len(holidays) != 11 {
t.Fatalf("expected 11 federal holidays for 2026, got %d", len(holidays))
}
// Spot-check Easter-relative entries against the algorithm output.
easter := time.Date(2026, 4, 5, 0, 0, 0, 0, time.UTC)
wantDates := map[string]bool{
"2026-01-01": true, // Neujahr
easter.AddDate(0, 0, -2).Format("2006-01-02"): true, // Karfreitag
easter.AddDate(0, 0, 50).Format("2006-01-02"): true, // Pfingstmontag
"2026-12-26": true, // 2. Weihnachtstag
}
got := map[string]bool{}
for _, h := range holidays {
got[h.Date.Format("2006-01-02")] = true
}
for d := range wantDates {
if !got[d] {
t.Errorf("expected %s in federal holidays for 2026, missing", d)
}
}
}
func TestIsNonWorkingDay_NoDB(t *testing.T) {
// Without a DB, only the German federal hardcoded set + weekends apply.
s := NewHolidayService(nil)
// 2026-01-01 is Neujahr (Thursday)
if !s.IsNonWorkingDay(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), "DE", "UPC") {
t.Error("Neujahr 2026 should be non-working")
}
// Saturday: 2026-01-03
if !s.IsNonWorkingDay(time.Date(2026, 1, 3, 0, 0, 0, 0, time.UTC), "DE", "UPC") {
t.Error("Saturday should be non-working")
}
// Regular Tuesday: 2026-01-13
if s.IsNonWorkingDay(time.Date(2026, 1, 13, 0, 0, 0, 0, time.UTC), "DE", "UPC") {
t.Error("regular Tuesday should be working")
}
}
func TestAdjustForNonWorkingDays_NoDB(t *testing.T) {
s := NewHolidayService(nil)
// 2026-01-01 (Neujahr Thu) → 2026-01-02 (Fri, working)
got, _, adjusted := s.AdjustForNonWorkingDays(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), "DE", "UPC")
want := time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC)
if !got.Equal(want) || !adjusted {
t.Errorf("Neujahr: got %s adjusted=%v, want %s adjusted=true", got, adjusted, want)
}
// Saturday 2026-01-03 → Monday 2026-01-05
got, _, adjusted = s.AdjustForNonWorkingDays(time.Date(2026, 1, 3, 0, 0, 0, 0, time.UTC), "DE", "UPC")
want = time.Date(2026, 1, 5, 0, 0, 0, 0, time.UTC)
if !got.Equal(want) || !adjusted {
t.Errorf("Saturday: got %s adjusted=%v, want %s adjusted=true", got, adjusted, want)
}
// Regular Tuesday 2026-01-13 → unchanged
in := time.Date(2026, 1, 13, 0, 0, 0, 0, time.UTC)
got, _, adjusted = s.AdjustForNonWorkingDays(in, "DE", "UPC")
if !got.Equal(in) || adjusted {
t.Errorf("Tuesday: got %s adjusted=%v, want %s adjusted=false", got, adjusted, in)
}
}
// AdjustForNonWorkingDaysWithReason classifies the dominant cause and (for
// the vacation case) reports the contiguous block boundaries — feeds
// /tools/fristenrechner's "Verschoben wegen UPC-Sommerferien (27.7.28.8.)"
// label, replacing the generic "Wochenende/Feiertag" string. See t-paliad-119.
func TestAdjustForNonWorkingDaysWithReason_Weekend(t *testing.T) {
s := NewHolidayService(nil) // German federal holidays only — sufficient for weekend / public-holiday cases.
// Saturday 2026-01-03 → Monday 2026-01-05, Kind = "weekend".
in := time.Date(2026, 1, 3, 0, 0, 0, 0, time.UTC)
adj, _, wasAdj, reason := s.AdjustForNonWorkingDaysWithReason(in, "DE", "UPC")
if !wasAdj || reason == nil {
t.Fatalf("expected adjustment + reason, got wasAdj=%v reason=%v", wasAdj, reason)
}
if reason.Kind != "weekend" {
t.Errorf("Kind: got %q, want weekend", reason.Kind)
}
if reason.OriginalWeekday != "Saturday" {
t.Errorf("OriginalWeekday: got %q, want Saturday", reason.OriginalWeekday)
}
if len(reason.Holidays) != 0 {
t.Errorf("Holidays should be empty for weekend-only, got %d", len(reason.Holidays))
}
want := time.Date(2026, 1, 5, 0, 0, 0, 0, time.UTC)
if !adj.Equal(want) {
t.Errorf("adjusted: got %s, want %s", adj, want)
}
}
func TestAdjustForNonWorkingDaysWithReason_PublicHoliday(t *testing.T) {
s := NewHolidayService(nil)
// Karfreitag 2025 (Fri 2025-04-18) → Tue 2025-04-22 (skips Sa/Su +
// Ostersonntag + Ostermontag). All three are public_holidays in the
// hardcoded set; Holidays should contain at least Karfreitag and
// Ostermontag (Ostersonntag too — it is a federal holiday entry).
in := time.Date(2025, 4, 18, 0, 0, 0, 0, time.UTC)
adj, _, wasAdj, reason := s.AdjustForNonWorkingDaysWithReason(in, "DE", "UPC")
if !wasAdj || reason == nil {
t.Fatalf("expected adjustment + reason, got wasAdj=%v reason=%v", wasAdj, reason)
}
if reason.Kind != "public_holiday" {
t.Errorf("Kind: got %q, want public_holiday", reason.Kind)
}
want := time.Date(2025, 4, 22, 0, 0, 0, 0, time.UTC)
if !adj.Equal(want) {
t.Errorf("adjusted: got %s, want %s", adj, want)
}
names := map[string]bool{}
for _, h := range reason.Holidays {
names[h.Name] = true
}
if !names["Karfreitag"] {
t.Errorf("Holidays should contain Karfreitag, got %v", names)
}
if !names["Ostermontag"] {
t.Errorf("Holidays should contain Ostermontag, got %v", names)
}
if reason.VacationName != "" || reason.VacationStart != "" {
t.Errorf("public_holiday reason should not set vacation fields, got %+v", reason)
}
}
// TestVacationDoesNotShiftDeadlines locks t-paliad-121: UPC judicial vacations
// (summer + winter, holiday_type='vacation') are informational metadata —
// the Court continues to operate during them per UPC AC decision 2023-05-26
// (decision-on-judicial-vacation), so they must NOT extend procedural
// deadlines. Only weekends + closure-type holidays (German federal public
// holidays) shift.
//
// Pre-fix this case adjusted Tue 2026-08-04 → Mon 2026-08-31 (a 27-day
// shift across the entire summer vacation), which was wrong on two axes
// (m's correction 2026-05-04): (a) the shift shouldn't happen at all, and
// (b) 4 Aug 2026 is a regular Tuesday so even if vacation did shift,
// landing 27 days later was a math artefact of walking the seeded run.
func TestVacationDoesNotShiftDeadlines(t *testing.T) {
// Inject UPC summer + winter vacations 2026 directly into the cache (no
// DB) so the test reflects the production seed without needing a live DB.
s := NewHolidayService(nil)
holidays := make([]Holiday, 0, 11+25+5)
holidays = append(holidays, germanFederalHolidays(2026)...)
addVacationRun := func(name string, start, end time.Time) {
for d := start; !d.After(end); d = d.AddDate(0, 0, 1) {
if wd := d.Weekday(); wd == time.Saturday || wd == time.Sunday {
continue // matches migration 010 — weekdays only
}
holidays = append(holidays, Holiday{Date: d, Name: name, Regime: RegimeUPC, IsVacation: true})
}
}
addVacationRun("UPC Summer Vacation",
time.Date(2026, 7, 27, 0, 0, 0, 0, time.UTC),
time.Date(2026, 8, 28, 0, 0, 0, 0, time.UTC))
addVacationRun("UPC Winter Vacation",
time.Date(2026, 12, 24, 0, 0, 0, 0, time.UTC),
time.Date(2026, 12, 31, 0, 0, 0, 0, time.UTC))
entry := &yearEntry{holidays: holidays}
entry.once.Do(func() {}) // mark loaded so loadYear is not invoked.
s.cache.Store(2026, entry)
// (a) Tue 2026-08-04 — m's reproduction. Inside UPC summer vacation,
// but a regular working Tuesday for the Court → no shift.
summerWeekday := time.Date(2026, 8, 4, 0, 0, 0, 0, time.UTC)
if s.IsNonWorkingDay(summerWeekday, "DE", "UPC") {
t.Errorf("2026-08-04 (UPC summer vacation, Tue): IsNonWorkingDay=true, want false")
}
if h := s.IsHoliday(summerWeekday, "DE", "UPC"); h == nil || !h.IsVacation {
t.Errorf("2026-08-04: IsHoliday should still surface the vacation entry, got %v", h)
}
adj, _, wasAdj, reason := s.AdjustForNonWorkingDaysWithReason(summerWeekday, "DE", "UPC")
if wasAdj || reason != nil {
t.Errorf("2026-08-04: wasAdj=%v reason=%v, want no adjustment", wasAdj, reason)
}
if !adj.Equal(summerWeekday) {
t.Errorf("2026-08-04: adjusted=%s, want %s", adj, summerWeekday)
}
// (b) Mon 2026-12-28 — inside UPC winter vacation, but a working Monday
// between Christmas and Neujahr for the Court → no shift.
winterWeekday := time.Date(2026, 12, 28, 0, 0, 0, 0, time.UTC)
if s.IsNonWorkingDay(winterWeekday, "DE", "UPC") {
t.Errorf("2026-12-28 (UPC winter vacation, Mon): IsNonWorkingDay=true, want false")
}
adj, _, wasAdj, _ = s.AdjustForNonWorkingDaysWithReason(winterWeekday, "DE", "UPC")
if wasAdj || !adj.Equal(winterWeekday) {
t.Errorf("2026-12-28: wasAdj=%v adjusted=%s, want no shift from %s", wasAdj, adj, winterWeekday)
}
// (c) Christmas Day 2026 (Fri, public_holiday) still shifts. Walks Fri
// (Christmas) → Sa 26 (2. Weihnachtstag) → Su 27 (weekend) → Mo
// 2026-12-28. Mon 28 IS in UPC winter vacation, but that entry is
// informational only (IsClosure=false) so the walk stops there.
christmas := time.Date(2026, 12, 25, 0, 0, 0, 0, time.UTC)
adj, _, wasAdj, reason = s.AdjustForNonWorkingDaysWithReason(christmas, "DE", "UPC")
if !wasAdj || reason == nil {
t.Fatalf("Christmas 2026: expected adjustment + reason, got wasAdj=%v reason=%v", wasAdj, reason)
}
if reason.Kind != "public_holiday" {
t.Errorf("Christmas 2026 Kind: got %q, want public_holiday", reason.Kind)
}
wantChristmas := time.Date(2026, 12, 28, 0, 0, 0, 0, time.UTC)
if !adj.Equal(wantChristmas) {
t.Errorf("Christmas 2026 adjusted: got %s, want %s (vacation entries skipped, not shifted to)", adj, wantChristmas)
}
// (d) Neujahr 2027 (Fri, public_holiday) → Mo 2027-01-04. Even though
// the UPC winter vacation continues through 6 Jan 2027, those days
// are informational only, so the shift stops at the next non-
// weekend / non-closure day.
neujahr := time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC)
if !s.IsNonWorkingDay(neujahr, "DE", "UPC") {
t.Errorf("Neujahr 2027: IsNonWorkingDay=false, want true")
}
adj, _, wasAdj, _ = s.AdjustForNonWorkingDaysWithReason(neujahr, "DE", "UPC")
wantNeujahr := time.Date(2027, 1, 4, 0, 0, 0, 0, time.UTC)
if !wasAdj || !adj.Equal(wantNeujahr) {
t.Errorf("Neujahr 2027: adjusted=%s wasAdj=%v, want %s adjusted=true", adj, wasAdj, wantNeujahr)
}
// (e) Karfreitag 2026 (Fri 3 Apr) regression — DE public_holiday outside
// any UPC vacation, must still shift to Tue 2026-04-07 (Easter Mon).
karfreitag := time.Date(2026, 4, 3, 0, 0, 0, 0, time.UTC)
adj, _, wasAdj, reason = s.AdjustForNonWorkingDaysWithReason(karfreitag, "DE", "UPC")
if !wasAdj || reason == nil || reason.Kind != "public_holiday" {
t.Errorf("Karfreitag 2026: wasAdj=%v reason=%v, want public_holiday", wasAdj, reason)
}
wantKar := time.Date(2026, 4, 7, 0, 0, 0, 0, time.UTC)
if !adj.Equal(wantKar) {
t.Errorf("Karfreitag 2026 adjusted: got %s, want %s", adj, wantKar)
}
}
func TestAdjustForNonWorkingDaysWithReason_NoShift(t *testing.T) {
s := NewHolidayService(nil)
in := time.Date(2026, 1, 13, 0, 0, 0, 0, time.UTC) // regular Tuesday
adj, _, wasAdj, reason := s.AdjustForNonWorkingDaysWithReason(in, "DE", "UPC")
if wasAdj || reason != nil {
t.Errorf("regular Tuesday should not adjust; got wasAdj=%v reason=%v", wasAdj, reason)
}
if !adj.Equal(in) {
t.Errorf("adjusted: got %s, want %s", adj, in)
}
}
// Verifies the cache is concurrency-safe (audit §1.6 fix). Run with -race.
func TestLoadHolidaysForYear_ConcurrentReads(t *testing.T) {
s := NewHolidayService(nil)
var wg sync.WaitGroup
for i := 0; i < 50; i++ {
wg.Add(1)
go func(year int) {
defer wg.Done()
h, err := s.LoadHolidaysForYear(year)
if err != nil {
t.Errorf("year %d: %v", year, err)
}
if len(h) != 11 {
t.Errorf("year %d: got %d holidays, want 11", year, len(h))
}
}(2026 + (i % 4)) // 4 distinct years × 50 calls
}
wg.Wait()
}
// t-paliad-122: per-country / per-regime filtering. Same Holiday cache, but
// (country, regime) selects which entries apply. Confirms the design:
// - DE court (DE, "") → German federal only.
// - UPC LD München (DE, UPC) → DE federal + UPC vacations.
// - UPC LD Paris (FR, UPC) → no DE, gets UPC vacations + (eventually) FR.
// - LG München (DE, "") → German federal only, no UPC vacations.
func TestAppliesTo_CountryRegimeFilter(t *testing.T) {
s := NewHolidayService(nil)
// Inject a curated cache: DE federal + UPC summer vacation + an FR row.
rows := make([]Holiday, 0, 15)
rows = append(rows, germanFederalHolidays(2026)...)
rows = append(rows, Holiday{
Date: time.Date(2026, 8, 4, 0, 0, 0, 0, time.UTC),
Name: "UPC Summer Vacation",
Regime: RegimeUPC,
IsVacation: true,
})
rows = append(rows, Holiday{
Date: time.Date(2026, 7, 14, 0, 0, 0, 0, time.UTC),
Name: "Fête nationale",
Country: "FR",
IsClosure: true,
})
entry := &yearEntry{holidays: rows}
entry.once.Do(func() {})
s.cache.Store(2026, entry)
// Christmas: applies to anyone with country=DE.
christmas := time.Date(2026, 12, 25, 0, 0, 0, 0, time.UTC)
if h := s.IsHoliday(christmas, "DE", ""); h == nil || !h.IsClosure {
t.Errorf("DE court / Christmas: want closure, got %v", h)
}
if h := s.IsHoliday(christmas, "DE", "UPC"); h == nil || !h.IsClosure {
t.Errorf("UPC LD München / Christmas: want closure, got %v", h)
}
if h := s.IsHoliday(christmas, "FR", "UPC"); h != nil {
t.Errorf("UPC LD Paris / DE Christmas: want no match, got %v", h)
}
// UPC summer vacation row: only UPC-regime queries see it.
summerVac := time.Date(2026, 8, 4, 0, 0, 0, 0, time.UTC)
if h := s.IsHoliday(summerVac, "DE", ""); h != nil {
t.Errorf("LG München (DE only) / UPC vacation date: want no match, got %v", h)
}
if h := s.IsHoliday(summerVac, "DE", "UPC"); h == nil || !h.IsVacation {
t.Errorf("UPC LD München / UPC vacation: want vacation, got %v", h)
}
if h := s.IsHoliday(summerVac, "FR", "UPC"); h == nil || !h.IsVacation {
t.Errorf("UPC LD Paris / UPC vacation: want vacation, got %v", h)
}
// FR row: only FR queries see it.
bastille := time.Date(2026, 7, 14, 0, 0, 0, 0, time.UTC)
if h := s.IsHoliday(bastille, "DE", "UPC"); h != nil {
t.Errorf("UPC LD München / Bastille Day: want no match, got %v", h)
}
if h := s.IsHoliday(bastille, "FR", "UPC"); h == nil || !h.IsClosure {
t.Errorf("UPC LD Paris / Bastille Day: want closure, got %v", h)
}
if h := s.IsHoliday(bastille, "FR", ""); h == nil || !h.IsClosure {
t.Errorf("FR national court / Bastille Day: want closure, got %v", h)
}
}
// AppliesTo behaviour at the unit level — pinning the matching rule that
// drives every filter call above. Cheap regression net for future tweaks.
func TestAppliesTo_Rules(t *testing.T) {
cases := []struct {
name string
holiday Holiday
country string
regime string
want bool
}{
{"DE row, DE court", Holiday{Country: "DE"}, "DE", "", true},
{"DE row, FR court", Holiday{Country: "DE"}, "FR", "", false},
{"UPC row, DE court (no regime)", Holiday{Regime: "UPC"}, "DE", "", false},
{"UPC row, UPC LD München (DE, UPC)", Holiday{Regime: "UPC"}, "DE", "UPC", true},
{"UPC row, UPC LD Paris (FR, UPC)", Holiday{Regime: "UPC"}, "FR", "UPC", true},
{"DE+UPC overlap (UPC vacation seeded with country=DE)", Holiday{Country: "DE", Regime: "UPC"}, "FR", "UPC", true},
{"empty row never matches", Holiday{}, "DE", "UPC", false},
}
for _, tc := range cases {
got := tc.holiday.AppliesTo(tc.country, tc.regime)
if got != tc.want {
t.Errorf("%s: AppliesTo(%q, %q) = %v, want %v", tc.name, tc.country, tc.regime, got, tc.want)
}
}
}