CI / Env docs check (push) Successful in 17s
CI / Docker compose check (push) Successful in 19s
CI / Nginx config check (push) Successful in 20s
CI / Frontend deps check (push) Successful in 27s
CI / Frontend major deps (push) Failing after 27s
CI / Secrets scan (push) Successful in 39s
CI / Go build (push) Successful in 40s
CI / Frontend build (push) Successful in 43s
CI / Knip (push) Successful in 41s
CI / Frontend a11y check (push) Successful in 1m37s
CI / Go vet (prod) (push) Successful in 1m53s
CI / go mod tidy (push) Successful in 31s
CI / Frontend QC (audit) (push) Successful in 51s
CI / Go vet (dev) (push) Successful in 2m50s
CI / Staticcheck (prod) (push) Successful in 2m57s
CI / Staticcheck (dev) (push) Successful in 3m19s
CI / Go vulnerabilities (push) Successful in 1m35s
CI / golangci-lint (push) Successful in 3m37s
CI / Frontend QC (typecheck) (push) Successful in 1m35s
CI / Security scan (prod) (push) Successful in 4m10s
CI / Security scan (dev) (push) Successful in 4m38s
CI / Frontend QC (lint) (push) Successful in 1m58s
CI / Svelte strict check (push) Successful in 1m12s
CI / Tests (prod) (push) Successful in 3m8s
CI / Tests (dev) (push) Failing after 4m8s
CI / Race (prod) (push) Successful in 7m34s
CI / Race (dev) (push) Failing after 7m40s
765 lines
25 KiB
Go
765 lines
25 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)
|
|
|
|
now := clock.Now()
|
|
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, now.Add(-30*time.Minute)).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, now.Add(-15*time.Minute)).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")
|
|
}
|
|
}
|