package calendartest import ( "testing" "time" calendar "amadan.net/oficina/calendar/internal/calendar" ) // The two hours a year that break calendars, pinned against // Europe/Dublin 2026 — DST starts 29 March and ends 25 October, both at // 01:00 UTC. // // There is no correct answer here and Go does not promise one, so this // test does not assert correctness. It asserts THE ANSWER, so that a Go // upgrade or a tzdata bump that changes it fails loudly here rather // than moving somebody's meeting quietly. // // The two cases resolve differently and that is the point of testing // both: the gap takes the pre-transition offset, the fold takes the // post-transition one. func TestDSTGapAndFold(t *testing.T) { const dublin = "Europe/Dublin" t.Run("the gap moves forward", func(t *testing.T) { // Local 01:00 jumps to 02:00, so 01:30 never happens. got, err := calendar.ToUTC("2026-03-29T01:30", dublin, false) if err != nil { t.Fatalf("ToUTC: %v", err) } if want := int64(1774747800); got != want { t.Errorf("gap 01:30: unix = %d, want %d", got, want) } // Reading it back must land AFTER the written time, never // before: an event in the gap that moved backwards would // happen before the person expected it. back, err := calendar.InZone(got, dublin) if err != nil { t.Fatalf("InZone: %v", err) } if h, m := back.Hour(), back.Minute(); h != 2 || m != 30 { t.Errorf("gap 01:30 reads back as %02d:%02d, want 02:30", h, m) } }) t.Run("the fold takes the second occurrence", func(t *testing.T) { // Local 02:00 falls back to 01:00, so 01:30 happens twice: // once at 00:30 UTC (offset +1) and once at 01:30 UTC // (offset 0). Go returns the second. got, err := calendar.ToUTC("2026-10-25T01:30", dublin, false) if err != nil { t.Fatalf("ToUTC: %v", err) } if want := int64(1792891800); got != want { t.Errorf("fold 01:30: unix = %d, want %d (the second occurrence)", got, want) } }) } // A day is not always 24 hours long. On the two DST nights it is 23 or // 25, and a day view that added a fixed duration would run an hour into // the next day or stop an hour short of the end of this one. func TestDayBoundsFollowDSTRatherThanTwentyFourHours(t *testing.T) { const dublin = "Europe/Dublin" for _, tc := range []struct { name string day time.Time hours float64 }{ {"an ordinary day is 24 hours", time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC), 24}, {"the spring-forward day is 23", time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC), 23}, {"the fall-back day is 25", time.Date(2026, 10, 25, 12, 0, 0, 0, time.UTC), 25}, } { t.Run(tc.name, func(t *testing.T) { start, end, err := calendar.DayBounds(tc.day, dublin) if err != nil { t.Fatalf("DayBounds: %v", err) } if got := float64(end-start) / 3600; got != tc.hours { t.Errorf("day length = %v hours, want %v", got, tc.hours) } }) } } // An all-day event is a date, not an instant. Storing it as a timed // event at midnight UTC is the bug that shows a birthday on the wrong // day for everybody west of Greenwich. func TestAllDayIsMidnightInItsOwnZone(t *testing.T) { dublin, err := calendar.ToUTC("2026-09-02", "Europe/Dublin", true) if err != nil { t.Fatalf("ToUTC Dublin: %v", err) } tokyo, err := calendar.ToUTC("2026-09-02", "Asia/Tokyo", true) if err != nil { t.Fatalf("ToUTC Tokyo: %v", err) } if dublin == tokyo { t.Error("the same date in two zones produced the same instant; all-day is being stored as UTC midnight") } // Tokyo is ahead, so its midnight comes first. if tokyo >= dublin { t.Errorf("Tokyo midnight (%d) should precede Dublin midnight (%d)", tokyo, dublin) } } // An empty zone is UTC, never the server's local zone — which is an // accident of provisioning and would shift every unzoned event's day // boundaries the first time the fleet moved region. func TestEmptyZoneIsUTCNotTheServers(t *testing.T) { empty, err := calendar.ToUTC("2026-09-02T09:00", "", false) if err != nil { t.Fatalf("ToUTC: %v", err) } utc, err := calendar.ToUTC("2026-09-02T09:00", "UTC", false) if err != nil { t.Fatalf("ToUTC: %v", err) } if empty != utc { t.Errorf("empty zone = %d, UTC = %d; they must agree", empty, utc) } } func TestUnknownZoneIsRefused(t *testing.T) { if _, err := calendar.ToUTC("2026-09-02T09:00", "Mars/Olympus_Mons", false); err == nil { t.Error("an unknown zone was accepted") } }