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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user