fix(t-paliad-121): stop shifting deadlines for UPC court vacations

Per UPC AC decision 2023-05-26, the UPC has summer + winter judicial
vacations but the Court continues to operate during them — they do NOT
extend procedural deadlines. paliad's HolidayService was treating every
paliad.holidays row as a non-working day, including vacation entries, so
a deadline landing on Tue 2026-08-04 (a regular working Tuesday) was
incorrectly shifted to Mon 2026-08-31 by walking the entire summer-
vacation run.

Fix: gate IsNonWorkingDay on Holiday.IsClosure (true for public_holiday
and closure types, false for vacation). IsHoliday still returns the row
regardless of type — UI surfaces that want to flag "this date is inside
UPC vacation" can still ask. paliad.holidays data is unchanged: the UPC
vacation rows stay as informational metadata.

The Kind="vacation" branch of AdjustForNonWorkingDaysWithReason is now
unreachable in practice (every vacation entry is IsClosure=false, so the
walk loop never enters with a vacation as the cause). Left in place as
defensive code for any future vacation type that should shift.

Tests: replaced TestAdjustForNonWorkingDaysWithReason_Vacation (asserted
the old wrong behaviour) with TestVacationDoesNotShiftDeadlines covering
m's reproduction (Tue 2026-08-04 → no shift), winter-vacation no-shift
(Mon 2026-12-28), Christmas/Neujahr regression (still shift correctly,
and walk through informational vacation entries to land on the next
real working day), and a Karfreitag regression to lock public-holiday
shifts.
This commit is contained in:
m
2026-05-04 18:48:23 +02:00
parent 0587fc2296
commit 7461c4af49
2 changed files with 107 additions and 32 deletions

View File

@@ -113,24 +113,32 @@ func (s *HolidayService) IsHoliday(date time.Time) *Holiday {
return nil
}
// IsNonWorkingDay returns true on weekends or holidays.
// IsNonWorkingDay returns true on weekends or closure-type holidays.
//
// "Vacation" entries (today: UPC summer + winter judicial vacations per UPC
// AC decision 2023-05-26) are deliberately excluded — the Court continues to
// operate during them and they do not extend procedural deadlines (RoP /
// AC decision-on-judicial-vacation). They stay in paliad.holidays as
// informational metadata so callers of IsHoliday can still surface "this
// date overlaps with UPC vacation" if they want to. See t-paliad-121.
func (s *HolidayService) IsNonWorkingDay(date time.Time) bool {
if wd := date.Weekday(); wd == time.Saturday || wd == time.Sunday {
return true
}
return s.IsHoliday(date) != nil
h := s.IsHoliday(date)
return h != nil && h.IsClosure
}
// AdjustForNonWorkingDays moves the date forward to the next working day.
// Returns adjusted date, the original (unmodified) date, and whether any
// adjustment was made.
//
// The 60-iteration safety bound has to span the longest run of consecutive
// non-working days that paliad ever sees: UPC summer vacation (~33 days) +
// flanking weekends + a German federal holiday on the trailing edge. Pre-
// PR-3 the bound was 30, which silently bailed mid-vacation onto a Saturday
// — caught by the t-paliad-086 PR-2 smoke test (Statement of Defence on
// 2026-04-30 → adjusted incorrectly to 2026-08-29 instead of 2026-08-31).
// Since t-paliad-121 vacations are no longer non-working, so the longest
// real-world run is Karfreitag → Ostermontag (~4 days) or Christmas-eve
// weekend → Neujahr (~6 days). The 60-iter cap is over-provisioned but
// kept as-is — it predates t-paliad-121 (the t-paliad-086 PR-3 history
// note explains the original 30 → 60 bump for full-vacation walks), and
// over-provisioning is harmless here.
func (s *HolidayService) AdjustForNonWorkingDays(date time.Time) (adjusted time.Time, original time.Time, wasAdjusted bool) {
adjusted, original, wasAdjusted, _ = s.AdjustForNonWorkingDaysWithReason(date)
return adjusted, original, wasAdjusted

View File

@@ -156,44 +156,111 @@ func TestAdjustForNonWorkingDaysWithReason_PublicHoliday(t *testing.T) {
}
}
func TestAdjustForNonWorkingDaysWithReason_Vacation(t *testing.T) {
// Inject UPC summer vacation 2026 directly into the cache (no DB) so
// the test reflects the production seed without needing a live DB.
// 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)
holidays := make([]Holiday, 0, 11+25+5)
holidays = append(holidays, germanFederalHolidays(2026)...)
for d := time.Date(2026, 7, 27, 0, 0, 0, 0, time.UTC); !d.After(time.Date(2026, 8, 28, 0, 0, 0, 0, time.UTC)); d = d.AddDate(0, 0, 1) {
if wd := d.Weekday(); wd == time.Saturday || wd == time.Sunday {
continue // matches migration 010 — weekdays only
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, IsVacation: true})
}
holidays = append(holidays, Holiday{Date: d, Name: "UPC Summer Vacation", 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)
// Tue 2026-08-04 (m's reproduction): inside UPC summer vacation
// adjusted to Mon 2026-08-31, with vacation span 27.7.28.8.
in := time.Date(2026, 8, 4, 0, 0, 0, 0, time.UTC)
adj, _, wasAdj, reason := s.AdjustForNonWorkingDaysWithReason(in)
// (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) {
t.Errorf("2026-08-04 (UPC summer vacation, Tue): IsNonWorkingDay=true, want false")
}
if h := s.IsHoliday(summerWeekday); 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)
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) {
t.Errorf("2026-12-28 (UPC winter vacation, Mon): IsNonWorkingDay=true, want false")
}
adj, _, wasAdj, _ = s.AdjustForNonWorkingDaysWithReason(winterWeekday)
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)
if !wasAdj || reason == nil {
t.Fatalf("expected adjustment + reason, got wasAdj=%v reason=%v", wasAdj, reason)
t.Fatalf("Christmas 2026: expected adjustment + reason, got wasAdj=%v reason=%v", wasAdj, reason)
}
if reason.Kind != "vacation" {
t.Errorf("Kind: got %q, want vacation", reason.Kind)
if reason.Kind != "public_holiday" {
t.Errorf("Christmas 2026 Kind: got %q, want public_holiday", reason.Kind)
}
if reason.VacationName != "UPC Summer Vacation" {
t.Errorf("VacationName: got %q, want UPC Summer Vacation", reason.VacationName)
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)
}
if reason.VacationStart != "2026-07-27" {
t.Errorf("VacationStart: got %q, want 2026-07-27", reason.VacationStart)
// (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) {
t.Errorf("Neujahr 2027: IsNonWorkingDay=false, want true")
}
if reason.VacationEnd != "2026-08-28" {
t.Errorf("VacationEnd: got %q, want 2026-08-28", reason.VacationEnd)
adj, _, wasAdj, _ = s.AdjustForNonWorkingDaysWithReason(neujahr)
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)
}
wantAdj := time.Date(2026, 8, 31, 0, 0, 0, 0, time.UTC)
if !adj.Equal(wantAdj) {
t.Errorf("adjusted: got %s, want %s", adj, wantAdj)
// (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)
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)
}
}