Files
Crussell/backend/handlers/today/today_test.go
T
popertots a6cb803ff2 test: fix today_test flakiness with London-timezone-aware booking times
Replace clock.Now() with fixed London-timezone times in TestGetCurrentNext_WithMultipleDataPoints to prevent near-midnight UTC failures where bookings fall outside the London 'today' window.
2026-08-22 00:34:49 +01:00

772 lines
26 KiB
Go

//go:build test
package today
import (
"context"
"encoding/json"
"math"
"net/http"
"net/http/httptest"
"testing"
"time"
"crussell/clock"
"crussell/db"
"crussell/testutils"
"crussell/testutils/fixtures"
)
func createTodayService(t *testing.T, ctx context.Context, q db.Querier) string {
t.Helper()
var svcID string
err := q.QueryRow(ctx, `
INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required)
VALUES ('Test Service', 'Description', 50.00, 60, true, 16)
RETURNING id
`).Scan(&svcID)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
return svcID
}
func addBookingService(t *testing.T, ctx context.Context, q db.Querier, bookingID, serviceID string) {
t.Helper()
_, err := q.Exec(ctx, `
INSERT INTO booking_services (booking_id, service_id)
VALUES ($1, $2)
`, bookingID, serviceID)
if err != nil {
t.Fatalf("failed to add booking service: %v", err)
}
}
func TestGetTodayAppointments_ShowsPreviousNameInAppointment(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)
}
var origFirstName, origLastName string
err = tx.QueryRow(ctx, `SELECT n_first_name, n_last_name FROM users WHERE id = $1`, userID).Scan(&origFirstName, &origLastName)
if err != nil {
t.Fatalf("failed to query user name: %v", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO name_history (user_id, previous_first_name, previous_last_name)
VALUES ($1, 'OldFirst', 'OldLast')
`, userID)
if err != nil {
t.Fatalf("failed to insert name_history: %v", err)
}
svcID := createTodayService(t, ctx, tx)
var bookingID string
now := clock.Now()
// Use London-aligned date so the booking falls within GetTodayAppointmentsHandler's
// London-midnight range. At BST boundary (23:00-23:59 UTC), UTC date != London date.
londonNow := now.In(londonLocation)
bookingStart := time.Date(londonNow.Year(), londonNow.Month(), londonNow.Day(), 10, 0, 0, 0, londonLocation)
err = tx.QueryRow(ctx, `
INSERT INTO bookings (user_id, start_time, status)
VALUES ($1, $2, 'in_progress')
RETURNING id
`, userID, bookingStart).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/appointments", nil)
req = req.WithContext(ctx)
rr := httptest.NewRecorder()
GetTodayAppointmentsHandler(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
}
var resp TodayAppointmentsResponse
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if len(resp.Appointments) == 0 {
t.Fatal("expected at least 1 appointment")
}
found := false
for _, a := range resp.Appointments {
if a.UserID == userID {
found = true
if a.PreviousFirstName == nil || *a.PreviousFirstName != "OldFirst" {
t.Errorf("expected previousFirstName 'OldFirst', got %v", a.PreviousFirstName)
}
if a.PreviousLastName == nil || *a.PreviousLastName != "OldLast" {
t.Errorf("expected previousLastName 'OldLast', got %v", a.PreviousLastName)
}
}
}
if !found {
t.Error("expected appointment for test user not found in response")
}
}
func TestGetTodayAppointments_OmitsPreviousNameWhenNoHistory(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)
var bookingID string
err = tx.QueryRow(ctx, `
INSERT INTO bookings (user_id, start_time, status)
VALUES ($1, NOW() + INTERVAL '5 minutes', 'in_progress')
RETURNING id
`, userID).Scan(&bookingID)
if err != nil {
t.Fatalf("failed to insert test booking: %v", err)
}
addBookingService(t, ctx, tx, bookingID, svcID)
req := httptest.NewRequest(http.MethodGet, "/api/admin/today/appointments", nil)
req = req.WithContext(ctx)
rr := httptest.NewRecorder()
GetTodayAppointmentsHandler(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
}
var resp TodayAppointmentsResponse
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
for _, a := range resp.Appointments {
if a.UserID == userID {
if a.PreviousFirstName != nil {
t.Errorf("expected previousFirstName nil (no history), got %v", *a.PreviousFirstName)
}
if a.PreviousLastName != nil {
t.Errorf("expected previousLastName nil (no history), got %v", *a.PreviousLastName)
}
}
}
}
// TestGetTodayAppointments_BST_Boundary verifies that GetTodayAppointmentsHandler
// uses London-aligned date boundaries, not UTC. A booking at 23:30 UTC on a BST
// day (which is 00:30 BST the next day) should NOT appear in today's appointments
// — it belongs to the next BST day. With the old UTC boundary it would be included
// because 23:30 >= 00:00 UTC.
func TestGetTodayAppointments_BST_Boundary(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)
// Create a booking at a time that falls AFTER today's London boundary.
// Use clock.Now() (UTC) to get the current time, then set the booking
// to 23:30 UTC if currently BST (UTC+1 would make 23:30 UTC = 00:30 BST next day)
// or 22:30 UTC if currently GMT (22:30 UTC = 22:30 GMT, same day).
now := clock.Now()
londonNow := now.In(londonLocation)
// Create booking at 23:30 UTC — during BST this is 00:30 BST the next day.
// The booking should NOT appear in today's appointments since it's tomorrow in London.
bkTime := time.Date(now.Year(), now.Month(), now.Day(), 23, 30, 0, 0, time.UTC)
var bookingID string
err = tx.QueryRow(ctx, `
INSERT INTO bookings (user_id, start_time, status)
VALUES ($1, $2, 'confirmed')
RETURNING id
`, userID, bkTime).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/appointments", nil)
req = req.WithContext(ctx)
rr := httptest.NewRecorder()
GetTodayAppointmentsHandler(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
}
var resp TodayAppointmentsResponse
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
// The booking is in today's range only if its London date matches
// the current London date.
bkLondonDate := bkTime.In(londonLocation).YearDay()
todayLondonDate := londonNow.YearDay()
bookingIsToday := bkLondonDate == todayLondonDate
if bookingIsToday {
if len(resp.Appointments) == 0 {
t.Error("expected the BST-boundary booking to appear in today's appointments (booking is today in London)")
}
} else {
if len(resp.Appointments) > 0 {
// Booking at 23:30 UTC is tomorrow in London (00:30 BST).
// With the old UTC boundary, this would INCORRECTLY appear in today's list.
t.Error("expected 0 appointments — the BST-boundary booking at 23:30 UTC is tomorrow in London and should NOT appear in today's list (BUG-2 fix)")
}
}
}
func TestGetTodayAppointments_Empty(t *testing.T) {
t.Parallel()
ctx, _ := testutils.SetupTestTx(t)
req := httptest.NewRequest(http.MethodGet, "/api/admin/today/appointments", nil)
req = req.WithContext(ctx)
rr := httptest.NewRecorder()
GetTodayAppointmentsHandler(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
}
var resp TodayAppointmentsResponse
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if resp.Appointments == nil {
t.Error("expected empty array, got nil")
}
if len(resp.Appointments) != 0 {
t.Errorf("expected 0 appointments, got %d", len(resp.Appointments))
}
}
func TestGetPendingApprovals_ShowsPreviousName(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)
}
var origFirstName, origLastName string
err = tx.QueryRow(ctx, `SELECT n_first_name, n_last_name FROM users WHERE id = $1`, userID).Scan(&origFirstName, &origLastName)
if err != nil {
t.Fatalf("failed to query user name: %v", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO name_history (user_id, previous_first_name, previous_last_name)
VALUES ($1, 'OldFirst', 'OldLast')
`, userID)
if err != nil {
t.Fatalf("failed to insert name_history: %v", err)
}
svcID := createTodayService(t, ctx, tx)
var bookingID string
err = tx.QueryRow(ctx, `
INSERT INTO bookings (user_id, start_time, status)
VALUES ($1, NOW() + INTERVAL '1 day', 'pending')
RETURNING id
`, userID).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/pending-approvals", nil)
req = req.WithContext(ctx)
rr := httptest.NewRecorder()
GetPendingApprovalsHandler(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
}
var resp PendingApprovalsResponse
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if len(resp.Approvals) == 0 {
t.Fatal("expected at least 1 pending approval")
}
found := false
for _, a := range resp.Approvals {
if a.UserID == userID {
found = true
if a.PreviousFirstName == nil || *a.PreviousFirstName != "OldFirst" {
t.Errorf("expected previousFirstName 'OldFirst', got %v", a.PreviousFirstName)
}
if a.PreviousLastName == nil || *a.PreviousLastName != "OldLast" {
t.Errorf("expected previousLastName 'OldLast', got %v", a.PreviousLastName)
}
}
}
if !found {
t.Error("expected pending approval for test user not found")
}
}
func TestGetPendingApprovals_OmitsPreviousNameWhenNoHistory(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)
var bookingID string
err = tx.QueryRow(ctx, `
INSERT INTO bookings (user_id, start_time, status)
VALUES ($1, NOW() + INTERVAL '1 day', 'pending')
RETURNING id
`, userID).Scan(&bookingID)
if err != nil {
t.Fatalf("failed to insert test booking: %v", err)
}
addBookingService(t, ctx, tx, bookingID, svcID)
req := httptest.NewRequest(http.MethodGet, "/api/admin/today/pending-approvals", nil)
req = req.WithContext(ctx)
rr := httptest.NewRecorder()
GetPendingApprovalsHandler(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
}
var resp PendingApprovalsResponse
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
for _, a := range resp.Approvals {
if a.UserID == userID {
if a.PreviousFirstName != nil {
t.Errorf("expected previousFirstName nil (no history), got %v", *a.PreviousFirstName)
}
if a.PreviousLastName != nil {
t.Errorf("expected previousLastName nil (no history), got %v", *a.PreviousLastName)
}
}
}
}
func TestGetPendingApprovals_Empty(t *testing.T) {
t.Parallel()
ctx, _ := testutils.SetupTestTx(t)
req := httptest.NewRequest(http.MethodGet, "/api/admin/today/pending-approvals", nil)
req = req.WithContext(ctx)
rr := httptest.NewRecorder()
GetPendingApprovalsHandler(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
}
var resp PendingApprovalsResponse
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if resp.Approvals == nil {
t.Error("expected empty array, got nil")
}
}
func TestGetCurrentNext_ShowsPreviousNameInAppointment(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)
}
_, err = tx.Exec(ctx, `
INSERT INTO name_history (user_id, previous_first_name, previous_last_name)
VALUES ($1, 'OldFirst', 'OldLast')
`, userID)
if err != nil {
t.Fatalf("failed to insert name_history: %v", err)
}
svcID := createTodayService(t, ctx, tx)
// Ensure working hours for all weekdays
for wd := 0; wd <= 6; wd++ {
tx.Exec(ctx, `
INSERT INTO working_hours (weekday, start_time, end_time, is_open)
VALUES ($1, '09:00', '17:00', true)
ON CONFLICT (weekday) DO UPDATE SET start_time = '09:00', end_time = '17:00', is_open = true
`, wd)
}
var bookingID2 string
err = tx.QueryRow(ctx, `
INSERT INTO bookings (user_id, start_time, status)
VALUES ($1, NOW(), 'confirmed')
RETURNING id
`, userID).Scan(&bookingID2)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
addBookingService(t, ctx, tx, bookingID2, 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.Current == nil {
t.Fatal("expected current appointment, got nil")
}
if resp.Current.User == nil {
t.Fatal("expected user info on current appointment")
}
if resp.Current.User.PreviousFirstName == nil || *resp.Current.User.PreviousFirstName != "OldFirst" {
t.Errorf("expected previousFirstName 'OldFirst', got %v", resp.Current.User.PreviousFirstName)
}
if resp.Current.User.PreviousLastName == nil || *resp.Current.User.PreviousLastName != "OldLast" {
t.Errorf("expected previousLastName 'OldLast', got %v", resp.Current.User.PreviousLastName)
}
}
// TestIsDayOpen_BST_Boundary verifies that isDayOpen receives a London-aligned
// time when called from GetCurrentAndNextHandler (BUG-4 fix). We call isDayOpen
// directly with a London time at the BST midnight boundary (23:30 UTC = 00:30 BST)
// to confirm the weekday lookup is correct — it should check the BST date, not UTC.
func TestIsDayOpen_BST_Boundary(t *testing.T) {
t.Parallel()
ctx, _ := testutils.SetupTestTx(t)
req := httptest.NewRequest(http.MethodGet, "/api/admin/today/current-next", nil)
req = req.WithContext(ctx)
// 23:30 UTC on a Monday in BST = 00:30 BST on Tuesday.
// isDayOpen should check Tuesday's (weekday 1) schedule, not Monday's.
// Working hours seed data: all days 08:00-20:00, all open.
monday2300UTC := time.Date(2099, 6, 14, 23, 30, 0, 0, time.UTC)
result := isDayOpen(req, monday2300UTC.In(londonLocation))
if !result {
t.Error("expected isDayOpen=true for Monday 23:30 UTC = Tuesday 00:30 BST (Tuesday is open)")
}
// Also verify UTC time without London conversion gives wrong result.
// At 23:30 UTC Monday, the UTC weekday is Monday, but the London weekday
// has already ticked over to Tuesday. Without .In(londonLocation), it would
// check Monday's hours. This test documents that the fix passes London time.
utcResult := isDayOpen(req, monday2300UTC)
if utcResult != result {
// This note documents that UTC-only and London conversion can differ
// at the BST midnight boundary, but since seed data has all days open,
// both return true in this case.
t.Log("note: isDayOpen returns different results at BST boundary (UTC vs London) — expected when Mon/Tue have different hours")
}
}
// TestFindWeekSummaryRange_AutumnDST verifies that findWeekSummaryRange
// handles the 25-hour day on Oct 25, 2026 (BST→GMT transition) correctly.
// The range boundaries must remain aligned to London midnight even when the
// day has an extra hour due to clocks going back.
func TestFindWeekSummaryRange_AutumnDST(t *testing.T) {
t.Parallel()
ctx, _ := testutils.SetupTestTx(t)
req := httptest.NewRequest(http.MethodGet, "/api/admin/today/current-next", nil)
req = req.WithContext(ctx)
// Oct 25, 2026 is the autumn DST transition (BST→GMT).
// At 02:00 BST (01:00 UTC) clocks go back to 01:00 GMT (01:00 UTC).
// London midnight start of Oct 25 = 2026-10-24 23:00 UTC (BST).
// London midnight end of Oct 25 = 2026-10-26 00:00 UTC (GMT).
londonInput := time.Date(2026, 10, 25, 12, 0, 0, 0, londonLocation)
start, end := findWeekSummaryRange(req, londonInput)
// Both boundaries must be in Europe/London timezone.
if start.Location().String() != "Europe/London" {
t.Errorf("expected start in Europe/London, got %s — DST boundary may be misaligned", start.Location())
}
if end.Location().String() != "Europe/London" {
t.Errorf("expected end in Europe/London, got %s — DST boundary may be misaligned", end.Location())
}
// Verify start is a London midnight (either 00:00 BST = 23:00 UTC prev day
// or 00:00 GMT = 00:00 UTC). Both are valid London midnights depending on
// which side of the transition boundary the workingStart falls.
startUTC := start.UTC()
if startUTC.Hour() != 23 && startUTC.Hour() != 0 {
t.Errorf("expected start boundary at London midnight (23:00 or 00:00 UTC), got hour=%d", startUTC.Hour())
}
// Verify end is a London midnight (same logic as start).
endUTC := end.UTC()
if endUTC.Hour() != 23 && endUTC.Hour() != 0 {
t.Errorf("expected end boundary at London midnight (23:00 or 00:00 UTC), got hour=%d", endUTC.Hour())
}
if !start.Before(end) {
t.Errorf("expected start (%v) to be before end (%v)", start, end)
}
// Verify the range represents whole calendar days (24h multiple), which is
// the invariant we care about — not whether a specific input falls inside.
duration := end.Sub(start)
if duration.Hours() < 24 || math.Mod(duration.Hours(), 24) != 0 {
t.Errorf("expected range duration to be a multiple of 24h (whole calendar days), got %v", duration)
}
}
// TestFindWeekSummaryRange_LondonTimezone verifies that findWeekSummaryRange
// returns date boundaries aligned to London midnight, not UTC (BUG 3+4 fix).
// The returned workingStart and workingEndEnd must use London timezone so the
// summary range correctly covers London business days at BST boundaries.
func TestFindWeekSummaryRange_LondonTimezone(t *testing.T) {
t.Parallel()
ctx, _ := testutils.SetupTestTx(t)
req := httptest.NewRequest(http.MethodGet, "/api/admin/today/current-next", nil)
req = req.WithContext(ctx)
// Use a London time as input (what the function should receive after fix).
londonInput := time.Date(2099, 6, 15, 12, 0, 0, 0, londonLocation)
start, end := findWeekSummaryRange(req, londonInput)
// The returned range boundaries should use londonLocation, not inherit
// UTC from the input. Verify by checking the Location().
if start.Location().String() != "Europe/London" {
t.Errorf("expected start boundary in Europe/London, got %s — without fix at line 489, .Location() inherits UTC", start.Location())
}
if end.Location().String() != "Europe/London" {
t.Errorf("expected end boundary in Europe/London, got %s — without fix at line 481, .Location() inherits UTC", end.Location())
}
// Verify that midnight in London is not midnight UTC on BST days.
// At BST, London midnight = 23:00 UTC the previous day.
startUTC := start.UTC()
if startUTC.Equal(start) {
startHour := startUTC.Hour()
if startHour != 23 && startHour != 0 {
t.Errorf("expected start boundary to be 23:00 UTC or 00:00 UTC (London midnight), got hour=%d", startHour)
}
}
// Also test with a BST boundary time input (23:30 UTC = 00:30 BST next day).
// Before BUG 3 fix, findWeekSummaryRange received UTC now, causing the
// date iteration to start from the wrong day.
bstBoundaryLondon := time.Date(2099, 6, 15, 0, 30, 0, 0, londonLocation) // 00:30 BST = 23:30 UTC previous day
start2, end2 := findWeekSummaryRange(req, bstBoundaryLondon)
if start2.Location().String() != "Europe/London" {
t.Errorf("BST boundary: expected start in Europe/London, got %s", start2.Location())
}
if end2.Location().String() != "Europe/London" {
t.Errorf("BST boundary: expected end in Europe/London, got %s", end2.Location())
}
}
func TestGetCurrentNext_WithData(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)
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)
_, err = tx.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount)
VALUES ($1, 'full', 'cash', 'completed', 50.00)
`, bookingID)
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
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.Current != nil {
t.Error("expected no current appointment (no in_progress bookings)")
}
if resp.Next != nil {
t.Error("expected no next appointment (no confirmed/pending bookings)")
}
if resp.DoneForDay == nil || !*resp.DoneForDay {
t.Error("expected doneForDay=true when no current/next appointments exist")
}
if resp.Summary == nil {
t.Fatal("expected summary when doneForDay is true")
}
if resp.Summary.TotalBookings < 1 {
t.Errorf("expected at least 1 booking in summary, got %d", resp.Summary.TotalBookings)
}
if resp.Summary.TotalPaymentsToday <= 0 {
t.Errorf("expected positive TotalPaymentsToday, got %.2f", resp.Summary.TotalPaymentsToday)
}
if resp.Summary.TotalDurationSpent <= 0 {
t.Errorf("expected positive TotalDurationSpent, got %d", resp.Summary.TotalDurationSpent)
}
if resp.Summary.TotalTipsToday != 0 {
t.Errorf("expected 0 tips (no tip payment created), got %.2f", resp.Summary.TotalTipsToday)
}
if resp.Summary.TotalBookings != 1 {
t.Errorf("expected exactly 1 booking, got %d", resp.Summary.TotalBookings)
}
if resp.Summary.CustomersServed != 1 {
t.Errorf("expected 1 customer served, got %d", resp.Summary.CustomersServed)
}
if resp.Summary.NewBookingServices == nil {
t.Error("expected new_booking_services field to be present (even if empty array from server)")
}
}
func TestGetCurrentNext_WithMultipleDataPoints(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
londonNow := clock.Now().In(clock.London)
// Use fixed times within today's London business hours so the handler's
// London-midnight date range always includes both bookings regardless of
// when the test runs (runs near midnight UTC could push one booking
// outside the London "today" window).
todayAt := func(h, m int) time.Time {
return time.Date(londonNow.Year(), londonNow.Month(), londonNow.Day(), h, m, 0, 0, clock.London).UTC()
}
svcID := createTodayService(t, ctx, tx)
user1ID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user 1: %v", err)
}
var booking1ID string
err = tx.QueryRow(ctx, `
INSERT INTO bookings (user_id, start_time, status)
VALUES ($1, $2, 'completed')
RETURNING id
`, user1ID, todayAt(10, 0)).Scan(&booking1ID)
if err != nil {
t.Fatalf("failed to create booking 1: %v", err)
}
addBookingService(t, ctx, tx, booking1ID, svcID)
_, err = tx.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount)
VALUES ($1, 'full', 'cash', 'completed', 5000)
`, booking1ID)
if err != nil {
t.Fatalf("failed to create full payment: %v", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount)
VALUES ($1, 'tip', 'cash', 'completed', 1000)
`, booking1ID)
if err != nil {
t.Fatalf("failed to create tip payment: %v", err)
}
user2ID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user 2: %v", err)
}
var booking2ID string
err = tx.QueryRow(ctx, `
INSERT INTO bookings (user_id, start_time, status)
VALUES ($1, $2, 'completed')
RETURNING id
`, user2ID, todayAt(11, 0)).Scan(&booking2ID)
if err != nil {
t.Fatalf("failed to create booking 2: %v", err)
}
addBookingService(t, ctx, tx, booking2ID, 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 {
if resp.Summary.TotalBookings < 2 {
t.Errorf("expected at least 2 bookings in summary, got %d", resp.Summary.TotalBookings)
}
if resp.Summary.TotalPaymentsToday < 50.00 {
t.Errorf("expected TotalPaymentsToday >= 50.00, got %.2f", resp.Summary.TotalPaymentsToday)
}
if resp.Summary.TotalTipsToday < 10.00 {
t.Errorf("expected TotalTipsToday >= 10.00, got %.2f", resp.Summary.TotalTipsToday)
}
if resp.Summary.TotalDurationSpent <= 0 {
t.Errorf("expected positive TotalDurationSpent, got %d", resp.Summary.TotalDurationSpent)
}
} else {
t.Error("expected summary data, got nil")
}
}