//go:build test package today // Strict timezone tests for today.go's `AT TIME ZONE 'Europe/London'` date // math. All assertions use fixed time.Date instants. import ( "encoding/json" "net/http" "net/http/httptest" "testing" "time" "crussell/clock" "crussell/db" "crussell/testutils" "crussell/testutils/fixtures" ) // TestToday_LondonDateWindow proves the SQL used by findNewBookingServices // (today.go) computes the London calendar date from a UTC instant. At // 2026-06-14 23:30 UTC (= 2026-06-15 00:30 BST) the London date is 2026-06-15 // even though the UTC date is 2026-06-14 — the 00:00-01:00 BST window where a // naive UTC date would start the 14-day "last working day" scan a day early. func TestToday_LondonDateWindow(t *testing.T) { t.Parallel() ctx, _ := testutils.SetupTestTx(t) londonDate := func(utc time.Time) string { t.Helper() var date string err := db.Conn.QueryRow(ctx, ` SELECT (($1::timestamptz AT TIME ZONE 'Europe/London')::date)::text `, utc).Scan(&date) if err != nil { t.Fatalf("London-date query failed for %s: %v", utc.Format(time.RFC3339), err) } return date } cases := []struct { name string utc time.Time want string }{ {"00:30 BST window", time.Date(2026, 6, 14, 23, 30, 0, 0, time.UTC), "2026-06-15"}, {"01:30 BST same London day", time.Date(2026, 6, 15, 0, 30, 0, 0, time.UTC), "2026-06-15"}, {"GMT winter day", time.Date(2026, 1, 15, 16, 0, 0, 0, time.UTC), "2026-01-15"}, {"spring-forward day", time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC), "2026-03-29"}, {"autumn-transition day", time.Date(2026, 10, 25, 12, 0, 0, 0, time.UTC), "2026-10-25"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { if got := londonDate(tc.utc); got != tc.want { t.Errorf("London date of %s UTC = %s, expected %s", tc.utc.Format(time.RFC3339), got, tc.want) } }) } } // TestToday_UTCUnchanged_Boundary pins that the same boundary instant is // stored as a TIMESTAMPTZ unchanged (UTC), so the Go-side clock.Now() and the // SQL-side NOW()/TIMESTAMPTZ never disagree about the instant itself. func TestToday_UTCUnchanged_Boundary(t *testing.T) { t.Parallel() ctx, _ := testutils.SetupTestTx(t) boundary := time.Date(2026, 6, 14, 23, 30, 0, 0, time.UTC) var roundTrip time.Time err := db.Conn.QueryRow(ctx, `SELECT $1::timestamptz`, boundary).Scan(&roundTrip) if err != nil { t.Fatalf("timestamptz round-trip failed: %v", err) } if !roundTrip.Equal(boundary) { t.Errorf("timestamptz round-trip altered the instant: got %s, want %s", roundTrip.Format(time.RFC3339Nano), boundary.Format(time.RFC3339Nano)) } // The London date derived from the round-tripped value must still be the // BST-day date — pgx may decode with a nil Go location, so only the instant // and its London wall-clock date matter. zone, off := roundTrip.In(londonLocation).Zone() if zone != "BST" || off != 3600 { t.Errorf("round-tripped instant in London = zone %q offset %ds, expected BST +3600", zone, off) } if got := roundTrip.In(londonLocation).Format("2006-01-02"); got != "2026-06-15" { t.Errorf("round-tripped instant maps to London date %s, expected 2026-06-15", got) } } // TestToday_SummaryDateLabels_LondonBusinessDay is a regression test for the // QA-flagged DST bug: the done-for-the-day summary header showed the PREVIOUS // day during BST. todayStart is London midnight converted to UTC (e.g. // 2026-08-04 00:00 BST = 2026-08-03 23:00 UTC), and formatting that UTC // instant directly with "2006-01-02" yielded "2026-08-03" for the Aug 4 // business day. The summary labels must use the London-located instant so the // header shows the business day (and the same date as the UTC-derived London // calendar day the rest of the pipeline uses). func TestToday_SummaryDateLabels_LondonBusinessDay(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) } svcID := createTodayService(t, ctx, tx) // Seed a completed booking TODAY (London) so the handler is in the // "done for the day" branch with a summary. now := clock.Now() var bookingID string err = tx.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status) VALUES ($1, $2, 'completed') RETURNING id `, userID, now).Scan(&bookingID) if err != nil { t.Fatalf("failed to create booking: %v", err) } addBookingService(t, ctx, tx, bookingID, svcID) req := httptest.NewRequest(http.MethodGet, "/api/admin/today/current-next", nil) req = req.WithContext(ctx) rr := httptest.NewRecorder() GetCurrentAndNextHandler(rr, req) if rr.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) } var resp CurrentNextResponse if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to unmarshal: %v", err) } if resp.Summary == nil { t.Fatal("expected a summary (done-for-the-day branch)") } londonToday := now.In(londonLocation).Format("2006-01-02") if resp.Summary.SummaryStartDate != londonToday { t.Errorf("summary_start_date = %s, expected %s (the London business day — the pre-fix code emitted the previous UTC day during BST)", resp.Summary.SummaryStartDate, londonToday) } // End date is the London day AFTER todayStart (todayEnd = todayStart + 24h, // which in London wall-clock is the NEXT day). londonTomorrow := now.In(londonLocation).AddDate(0, 0, 1).Format("2006-01-02") if resp.Summary.SummaryEndDate != londonTomorrow { t.Errorf("summary_end_date = %s, expected %s (the London day after the business day)", resp.Summary.SummaryEndDate, londonTomorrow) } }