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.
389 lines
16 KiB
Go
389 lines
16 KiB
Go
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)
|
||
}
|
||
}
|
||
}
|