Files
Crussell/backend/handlers/bookings/dst_timezone_test.go
T
popertots 197d4c4b9b Gift-card rolling expiry, SvelteDate→Date purge, strict DST tests, UTC scan-location + settings legal floor
Gift-card rolling expiry (setting-driven, was dead config):
- GetGiftCardExpiryMonths(): single source of truth (business_settings
  gift_card_expiry_months, fallback 24) shared by payment handlers and the
  CleanupExpiredGiftCards job (was hardcoded 24).
- expiry_date now maintained on ALL 9 gift-card write sites (buy, topup,
  transfer, redeem, terminal payment, refund credit, till) so the refund-time
  guard at refunds.go actually fires. Schema default 12->24 + migration note;
  test-DB seed aligned. Stale "expiry_date IS NULL" test rewritten; new
  expired-card-rejected regression test.

Frontend SvelteDate purge (docs' stated convention, wide):
- All 180+ raw `new SvelteDate(...)` uses across routes/components replaced
  with parseWallClockDate (backend UTC ISO) or new Date (wall-clock
  constructors). SvelteDate imports removed. timeSlots.ts getDayWithOrdinal
  fixed. Zero SvelteDate references remain; svelte-check clean.

Strict timezone/DST testing + QA fixes:
- 8 new hermetic boundary tests: clock.DST transitions (both 2026 folds),
  closing-hours GMT vs BST, booking date-window midnight, refund-tier
  elapsed-time independence, deposit-window UTC-instant, scheduling
  LondonDateString midnight, today AT TIME ZONE window + UTC round-trip.
- today.go summary date labels fixed to London wall-clock (were showing the
  previous UTC day during BST) + regression test.
- pgx ScanLocation fixed to UTC via AfterConnect (was host-local -> JSON
  offsets depended on deployment TZ, contradicting the documented UTC
  invariant) + regression test. Registered as a new *Type to avoid a data
  race on the shared type map (caught by -race).

Admin Business Settings (setting now functional => legal floor):
- gift_card_expiry_months validation floor raised 1 -> 12 months (CMA/
  Consumer Rights Act 2015 unfair-contract-term guidance) in endpoint + UI,
  with rolling-expiry semantics shown in both display and edit form.
- 3 new expiry validation tests; 2 pre-existing message assertions updated.

Full suite 25/25 + race clean via run-tests.sh lockfile; svelte-check 0
errors/warnings; production build succeeds.
2026-08-22 00:34:49 +01:00

174 lines
6.7 KiB
Go

//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
}