//go:build test && dev package bookings // Strict timezone/DST/midnight-boundary tests for the booking closing-hours // pipeline and the date-window queries. All assertions use fixed time.Date // instants (no wall-clock "now" drift) so they are deterministic on every run. import ( "net/http" "testing" "time" "crussell/clock" "crussell/testutils" "crussell/testutils/fixtures" "crussell/testutils/jwt" ) // TestCheckClosingHours_GMTvsBST proves the closing-time pipeline is // DST-safe: the same 16:30 wall-clock end is accepted and the same 17:30 // wall-clock end is rejected during BOTH a GMT season (2026-01-15, offset +0) // and a BST season (2026-06-15, offset +1). The same wall-clock instant maps // to UTC instants that differ by exactly the 1h DST offset between seasons — // the conversion via londonLocation must absorb that shift. func TestCheckClosingHours_GMTvsBST(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) // 2026-01-15 is a Thursday (DB weekday 3), 2026-06-15 is a Monday (DB // weekday 0). Pin closing at 17:00 on both so the resolution goes through // the real getClosingTimeForDate -> working_hours path. for _, wd := range []int{0, 3} { _, err := tx.Exec(ctx, ` INSERT INTO working_hours (weekday, start_time, end_time, is_open) VALUES ($1, '08:00', '17:00', true) ON CONFLICT (weekday) DO UPDATE SET start_time = '08:00', end_time = '17:00', is_open = true `, wd) if err != nil { t.Fatalf("failed to set working hours for weekday %d: %v", wd, err) } } cases := []struct { name string localEnd time.Time // Europe/London wall-clock booking end wantUTC int // expected UTC hour of that same instant }{ {"GMT 2026-01-15", time.Date(2026, 1, 15, 16, 30, 0, 0, clock.London), 16}, {"BST 2026-06-15", time.Date(2026, 6, 15, 16, 30, 0, 0, clock.London), 15}, } // The SAME 16:30 wall-clock end is 16:30 UTC in GMT and 15:30 UTC in BST — // a shift of exactly 1h (the DST offset). if diff := cases[0].localEnd.UTC().Hour() - cases[1].localEnd.UTC().Hour(); diff != 1 { t.Fatalf("expected the two 16:30-local ends to differ by exactly 1h in UTC, got %dh", diff) } for _, tc := range cases { t.Run(tc.name+" pass 16:30", func(t *testing.T) { weekday := int((tc.localEnd.Weekday() + 6) % 7) closeStr, err := getClosingTimeForDate(ctx, tx, weekday, tc.localEnd) if err != nil { t.Fatalf("getClosingTimeForDate failed: %v", err) } // The DB stores end_time as TIME so ::text returns "17:00:00". if closeStr != "17:00" && closeStr != "17:00:00" { t.Fatalf("expected closing 17:00 for %s, got %q", tc.name, closeStr) } if err := checkClosingHours(tc.localEnd, closeStr); err != nil { t.Errorf("16:30 local end on %s must pass closing 17:00, got: %v", tc.name, err) } if got := tc.localEnd.UTC().Hour(); got != tc.wantUTC { t.Errorf("16:30 local on %s should be %02d:30 UTC, got hour %d", tc.name, tc.wantUTC, got) } }) } failCases := []struct { name string localEnd time.Time }{ {"GMT 2026-01-15", time.Date(2026, 1, 15, 17, 30, 0, 0, clock.London)}, {"BST 2026-06-15", time.Date(2026, 6, 15, 17, 30, 0, 0, clock.London)}, } for _, tc := range failCases { t.Run(tc.name+" fail 17:30", func(t *testing.T) { if err := checkClosingHours(tc.localEnd, "17:00"); !IsPastClosing(err) { t.Errorf("17:30 local end on %s must be rejected, got: %v", tc.name, err) } }) } } // TestBookingDateWindow_DSTMidnight proves the booking date-window queries // (bookings.go GetAllUserBookingsHandler start_date/end_date conversion) bucket // bookings by their LONDON calendar date, not their UTC date. A booking at // 2026-06-14 23:30 UTC is 2026-06-15 00:30 BST — inside the 00:00-01:00 BST // window where the UTC date differs from the London date — and must appear in // the "2026-06-15" window, not "2026-06-14". func TestBookingDateWindow_DSTMidnight(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } // Precondition: 2026-06-14 23:30 UTC must be 2026-06-15 00:30 BST (the // UTC-date != London-date window). This is the exact property under test. boundary := time.Date(2026, 6, 14, 23, 30, 0, 0, time.UTC) if got := boundary.In(clock.London).Format("2006-01-02"); got != "2026-06-15" { t.Fatalf("test setup invariant: 2026-06-14 23:30 UTC must be London date 2026-06-15, got %s", got) } // Booking A: the midnight-boundary booking (00:30 BST on the 15th). bookingA, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, boundary) if err != nil { t.Fatalf("failed to create boundary booking: %v", err) } // Booking B: 22:30 UTC on the 14th = 23:30 BST on the 14th (clearly the 14th). bookingB, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, time.Date(2026, 6, 14, 22, 30, 0, 0, time.UTC)) if err != nil { t.Fatalf("failed to create day-before booking: %v", err) } token := jwt.GenerateUserToken(userID) // Window 2026-06-15: only the boundary booking belongs (00:30 BST on the // 15th); the 22:30-UTC booking is 23:30 BST on the 14th. w := makeRequest(http.HandlerFunc(GetAllUserBookingsHandler), "GET", "/api/bookings?start_date=2026-06-15&end_date=2026-06-15", nil, token, ctx) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var resp BookingListResponse if err := parseResponseBody(w, &resp); err != nil { t.Fatalf("failed to parse response: %v", err) } if resp.Total != 1 || len(resp.Bookings) != 1 || resp.Bookings[0].ID != bookingA { t.Errorf("window 2026-06-15: expected only booking A (%s) at 00:30 BST, got total=%d ids=%v", bookingA, resp.Total, bookingIDs(resp.Bookings)) } // Window 2026-06-14: only booking B belongs; the 23:30-UTC boundary booking // has already rolled over to London date 2026-06-15. w2 := makeRequest(http.HandlerFunc(GetAllUserBookingsHandler), "GET", "/api/bookings?start_date=2026-06-14&end_date=2026-06-14", nil, token, ctx) if w2.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w2.Code, w2.Body.String()) } var resp2 BookingListResponse if err := parseResponseBody(w2, &resp2); err != nil { t.Fatalf("failed to parse response: %v", err) } if resp2.Total != 1 || len(resp2.Bookings) != 1 || resp2.Bookings[0].ID != bookingB { t.Errorf("window 2026-06-14: expected only booking B (%s), got total=%d ids=%v", bookingB, resp2.Total, bookingIDs(resp2.Bookings)) } } func bookingIDs(bookings []Booking) []string { ids := make([]string, len(bookings)) for i, b := range bookings { ids[i] = b.ID } return ids }