Files
Crussell/backend/handlers/admin/today_test.go
T
popertotsandSisyphus e4b9003439 refactor(handlers): migrate remaining backend handlers to clock.Now() and transaction patterns
Apply clock.Now() migration, transaction wrapping, and minor refactors across admin, scheduling, today, user, auth handler, notifications, webhooks, services, portfolio, ratelimit, testutils, and main.go.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-24 23:43:50 +01:00

990 lines
34 KiB
Go

//go:build test
// +build test
package admin
// Package admin contains tests for admin dashboard "today" endpoints.
//
// Test Coverage:
// - GetCurrentAndNextHandler: GET /api/admin/today/current-next - Get current & next booking
// - GetTodayAppointmentsHandler: GET /api/admin/today/appointments - Get today's bookings
// - GetPendingApprovalsHandler: GET /api/admin/today/pending-approvals - Get pending bookings
// - Auto-status transitions: Silent background updates on GET requests
// - Closed day summary: done_for_day=true, summary_scope="week", total_bookings excludes cancelled
// - Week summary on tomorrow-closed: day summary + week_summary when tomorrow is closed
// - Exceptional hours: Exceptional closed day via groups + applications overrides defaults
//
// Authentication: All endpoints require admin role (403 for non-admins).
//
// Note: Notification tests are in handlers/notifications/notifications_test.go
import (
"encoding/json"
"net/http"
"testing"
"time"
"crussell/clock"
"crussell/testutils"
"crussell/handlers/notifications"
"crussell/handlers/today"
"crussell/mw"
)
// TestAdminToday_CurrentNext verifies that an admin can retrieve the currently
// in-progress booking and the next upcoming booking for the dashboard.
func TestAdminToday_CurrentNext(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
// Create test user
var userID string
err := tx.QueryRow(ctx, `
INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type)
VALUES ('Test', 'User', 'testuser@test.com', '+1234567890', '1990-01-01', 'hash', 'verified_email', 'email')
RETURNING id
`).Scan(&userID)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
// Create service
var serviceID string
err = tx.QueryRow(ctx, `
INSERT INTO services (name, description, price, duration_minutes, is_active)
VALUES ('Manicure', 'Basic manicure', 25.00, 30, true)
RETURNING id
`).Scan(&serviceID)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
// Create booking for today (in_progress)
_, err = tx.Exec(ctx, `
INSERT INTO bookings (user_id, start_time, status, created_at)
VALUES ($1, NOW(), 'in_progress', NOW())
`, userID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
// Get the booking ID
var bookingID string
err = tx.QueryRow(ctx, `
SELECT id FROM bookings WHERE user_id = $1 ORDER BY created_at DESC LIMIT 1
`, userID).Scan(&bookingID)
if err != nil {
t.Fatalf("failed to get booking ID: %v", err)
}
// Add service to booking
_, err = tx.Exec(ctx, `
INSERT INTO booking_services (booking_id, service_id)
VALUES ($1, $2)
`, bookingID, serviceID)
if err != nil {
t.Fatalf("failed to add service to booking: %v", err)
}
handler := http.HandlerFunc(today.GetCurrentAndNextHandler)
w := makeAdminRequest(handler, "GET", "/api/admin/today/current-next", nil, ctx)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var response today.CurrentNextResponse
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if response.Current == nil {
t.Errorf("expected current appointment, got nil")
}
if response.Current != nil && response.Current.ID != bookingID {
t.Errorf("expected booking ID %s, got %s", bookingID, response.Current.ID)
}
}
// TestAdminToday_CurrentNext_ClosingTime verifies that the current-next endpoint
// returns the closing time for today.
func TestAdminToday_CurrentNext_ClosingTime(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
// Seed working hours for today (DB uses 0=Monday, 6=Sunday)
todayWeekday := int(clock.Now().Weekday())
if todayWeekday == 0 {
todayWeekday = 6
} else {
todayWeekday -= 1
}
_, err := tx.Exec(ctx, `
INSERT INTO working_hours (weekday, start_time, end_time, is_open)
VALUES ($1, '09:00', '18:00', true)
ON CONFLICT (weekday) DO UPDATE SET start_time = '09:00', end_time = '18:00', is_open = true
`, todayWeekday)
if err != nil {
t.Fatalf("failed to seed working hours: %v", err)
}
handler := http.HandlerFunc(today.GetCurrentAndNextHandler)
w := makeAdminRequest(handler, "GET", "/api/admin/today/current-next", nil, ctx)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var response today.CurrentNextResponse
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if response.ClosingTime == nil {
t.Errorf("expected closing_time in response, got nil")
}
if response.ClosingTime != nil && *response.ClosingTime == "" {
t.Error("expected closing_time to be non-empty string")
}
if response.ClosingTime != nil && *response.ClosingTime != "18:00:00" && *response.ClosingTime != "18:00" {
t.Logf("got closing_time: %s", *response.ClosingTime)
}
}
// TestAdminToday_Appointments tests that an admin can get a list of all
// bookings scheduled for today with their details.
func TestAdminToday_Appointments(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
// Create test user
var userID string
err := tx.QueryRow(ctx, `
INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type)
VALUES ('Test', 'User', 'testuser@test.com', '+1234567890', '1990-01-01', 'hash', 'verified_email', 'email')
RETURNING id
`).Scan(&userID)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
// Create service
var serviceID string
err = tx.QueryRow(ctx, `
INSERT INTO services (name, description, price, duration_minutes, is_active)
VALUES ('Manicure', 'Basic manicure', 25.00, 30, true)
RETURNING id
`).Scan(&serviceID)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
// Create booking for today
_, err = tx.Exec(ctx, `
INSERT INTO bookings (user_id, start_time, status, created_at)
VALUES ($1, NOW(), 'confirmed', NOW())
`, userID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
// Get the booking ID
var bookingID string
err = tx.QueryRow(ctx, `
SELECT id FROM bookings WHERE user_id = $1 ORDER BY created_at DESC LIMIT 1
`, userID).Scan(&bookingID)
if err != nil {
t.Fatalf("failed to get booking ID: %v", err)
}
// Add service to booking
_, err = tx.Exec(ctx, `
INSERT INTO booking_services (booking_id, service_id)
VALUES ($1, $2)
`, bookingID, serviceID)
if err != nil {
t.Fatalf("failed to add service to booking: %v", err)
}
handler := http.HandlerFunc(today.GetTodayAppointmentsHandler)
w := makeAdminRequest(handler, "GET", "/api/admin/today/appointments", nil, ctx)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var response today.TodayAppointmentsResponse
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if len(response.Appointments) != 1 {
t.Errorf("expected 1 appointment, got %d", len(response.Appointments))
}
if len(response.Appointments) > 0 && response.Appointments[0].ID != bookingID {
t.Errorf("expected booking ID %s, got %s", bookingID, response.Appointments[0].ID)
}
}
// TestAdminToday_PendingApprovals verifies that an admin can see all pending
// bookings that require approval/confirmation.
func TestAdminToday_PendingApprovals(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
// Create test user
var userID string
err := tx.QueryRow(ctx, `
INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type)
VALUES ('Test', 'User', 'testuser@test.com', '+1234567890', '1990-01-01', 'hash', 'verified_email', 'email')
RETURNING id
`).Scan(&userID)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
// Create service
var serviceID string
err = tx.QueryRow(ctx, `
INSERT INTO services (name, description, price, duration_minutes, is_active)
VALUES ('Manicure', 'Basic manicure', 25.00, 30, true)
RETURNING id
`).Scan(&serviceID)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
// Create pending booking
_, err = tx.Exec(ctx, `
INSERT INTO bookings (user_id, start_time, status, created_at)
VALUES ($1, NOW() + INTERVAL '1 day', 'pending', NOW())
`, userID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
// Get the booking ID
var bookingID string
err = tx.QueryRow(ctx, `
SELECT id FROM bookings WHERE user_id = $1 ORDER BY created_at DESC LIMIT 1
`, userID).Scan(&bookingID)
if err != nil {
t.Fatalf("failed to get booking ID: %v", err)
}
// Add service to booking
_, err = tx.Exec(ctx, `
INSERT INTO booking_services (booking_id, service_id)
VALUES ($1, $2)
`, bookingID, serviceID)
if err != nil {
t.Fatalf("failed to add service to booking: %v", err)
}
handler := http.HandlerFunc(today.GetPendingApprovalsHandler)
w := makeAdminRequest(handler, "GET", "/api/admin/today/pending-approvals", nil, ctx)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var response today.PendingApprovalsResponse
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if len(response.Approvals) != 1 {
t.Errorf("expected 1 pending approval, got %d", len(response.Approvals))
}
if len(response.Approvals) > 0 && response.Approvals[0].ID != bookingID {
t.Errorf("expected booking ID %s, got %s", bookingID, response.Approvals[0].ID)
}
}
// =============================================================================
// Auto-Status Transition Tests
// =============================================================================
// TestAdminToday_AutoTransition_ConfirmedToInProgress verifies that a confirmed booking
// that has started but not yet ended is automatically transitioned to in_progress
// when fetching today's appointments.
//
// The transition happens silently in the background during GET requests, not via cron.
func TestAdminToday_AutoTransition_ConfirmedToInProgress(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
// Create test user
var userID string
err := tx.QueryRow(ctx, `
INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type)
VALUES ('Test', 'User', 'testuser@test.com', '+1234567890', '1990-01-01', 'hash', 'verified_email', 'email')
RETURNING id
`).Scan(&userID)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
// Create service with 30 minute duration
var serviceID string
err = tx.QueryRow(ctx, `
INSERT INTO services (name, description, price, duration_minutes, is_active)
VALUES ('Manicure', 'Basic manicure', 25.00, 30, true)
RETURNING id
`).Scan(&serviceID)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
// Create CONFIRMED booking that started 15 minutes ago (should be in progress)
// Start time = NOW - 15 minutes, duration = 30 minutes, so still ongoing
var bookingID string
err = tx.QueryRow(ctx, `
INSERT INTO bookings (user_id, start_time, status, created_at)
VALUES ($1, NOW() - INTERVAL '15 minutes', 'confirmed', NOW())
RETURNING id
`, userID).Scan(&bookingID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
// Add service to booking
_, err = tx.Exec(ctx, `
INSERT INTO booking_services (booking_id, service_id)
VALUES ($1, $2)
`, bookingID, serviceID)
if err != nil {
t.Fatalf("failed to add service to booking: %v", err)
}
// Call the handler - this should trigger auto-transition
handler := http.HandlerFunc(today.GetTodayAppointmentsHandler)
w := makeAdminRequest(handler, "GET", "/api/admin/today/appointments", nil, ctx)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
// Verify the booking status was changed to in_progress
var status string
err = tx.QueryRow(ctx, `
SELECT status FROM bookings WHERE id = $1
`, bookingID).Scan(&status)
if err != nil {
t.Fatalf("failed to query booking status: %v", err)
}
if status != "in_progress" {
t.Errorf("expected status 'in_progress' after auto-transition, got '%s'", status)
}
}
// TestAdminToday_AutoTransition_InProgressToCompleted verifies that an in_progress
// booking that has ended is automatically transitioned to completed when fetching
// today's appointments.
//
// The transition happens silently in the background during GET requests, not via cron.
func TestAdminToday_AutoTransition_InProgressToCompleted(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
// Create test user
var userID string
err := tx.QueryRow(ctx, `
INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type)
VALUES ('Test', 'User', 'testuser@test.com', '+1234567890', '1990-01-01', 'hash', 'verified_email', 'email')
RETURNING id
`).Scan(&userID)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
// Create service with 30 minute duration
var serviceID string
err = tx.QueryRow(ctx, `
INSERT INTO services (name, description, price, duration_minutes, is_active)
VALUES ('Manicure', 'Basic manicure', 25.00, 30, true)
RETURNING id
`).Scan(&serviceID)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
// Create IN_PROGRESS booking that ended 10 minutes ago
// Start time = NOW - 40 minutes, duration = 30 minutes, so ended 10 mins ago
var bookingID string
err = tx.QueryRow(ctx, `
INSERT INTO bookings (user_id, start_time, status, created_at)
VALUES ($1, NOW() - INTERVAL '40 minutes', 'in_progress', NOW())
RETURNING id
`, userID).Scan(&bookingID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
// Add service to booking
_, err = tx.Exec(ctx, `
INSERT INTO booking_services (booking_id, service_id)
VALUES ($1, $2)
`, bookingID, serviceID)
if err != nil {
t.Fatalf("failed to add service to booking: %v", err)
}
// Call the handler - this should trigger auto-transition
handler := http.HandlerFunc(today.GetTodayAppointmentsHandler)
w := makeAdminRequest(handler, "GET", "/api/admin/today/appointments", nil, ctx)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
// Verify the booking status was changed to completed
var status string
err = tx.QueryRow(ctx, `
SELECT status FROM bookings WHERE id = $1
`, bookingID).Scan(&status)
if err != nil {
t.Fatalf("failed to query booking status: %v", err)
}
if status != "completed" {
t.Errorf("expected status 'completed' after auto-transition, got '%s'", status)
}
}
// TestAdminToday_NoAutoTransition_BeforeStartTime verifies that a confirmed
// booking that hasn't started yet is NOT transitioned to in_progress.
func TestAdminToday_NoAutoTransition_BeforeStartTime(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
// Create test user
var userID string
err := tx.QueryRow(ctx, `
INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type)
VALUES ('Test', 'User', 'testuser@test.com', '+1234567890', '1990-01-01', 'hash', 'verified_email', 'email')
RETURNING id
`).Scan(&userID)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
// Create service
var serviceID string
err = tx.QueryRow(ctx, `
INSERT INTO services (name, description, price, duration_minutes, is_active)
VALUES ('Manicure', 'Basic manicure', 25.00, 30, true)
RETURNING id
`).Scan(&serviceID)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
// Create CONFIRMED booking that starts in 1 hour (should NOT transition)
var bookingID string
err = tx.QueryRow(ctx, `
INSERT INTO bookings (user_id, start_time, status, created_at)
VALUES ($1, NOW() + INTERVAL '1 hour', 'confirmed', NOW())
RETURNING id
`, userID).Scan(&bookingID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
// Add service to booking
_, err = tx.Exec(ctx, `
INSERT INTO booking_services (booking_id, service_id)
VALUES ($1, $2)
`, bookingID, serviceID)
if err != nil {
t.Fatalf("failed to add service to booking: %v", err)
}
// Call the handler
handler := http.HandlerFunc(today.GetTodayAppointmentsHandler)
w := makeAdminRequest(handler, "GET", "/api/admin/today/appointments", nil, ctx)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
// Verify the booking status is still 'confirmed' (not changed)
var status string
err = tx.QueryRow(ctx, `
SELECT status FROM bookings WHERE id = $1
`, bookingID).Scan(&status)
if err != nil {
t.Fatalf("failed to query booking status: %v", err)
}
if status != "confirmed" {
t.Errorf("expected status 'confirmed' (no auto-transition before start), got '%s'", status)
}
}
// TestAdminToday_AutoTransition_CurrentNextHandler verifies that auto-transition
// also works when calling GetCurrentAndNextHandler (not just appointments handler)
func TestAdminToday_AutoTransition_CurrentNextHandler(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
// Create test user
var userID string
err := tx.QueryRow(ctx, `
INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type)
VALUES ('Test', 'User', 'testuser@test.com', '+1234567890', '1990-01-01', 'hash', 'verified_email', 'email')
RETURNING id
`).Scan(&userID)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
// Create service
var serviceID string
err = tx.QueryRow(ctx, `
INSERT INTO services (name, description, price, duration_minutes, is_active)
VALUES ('Manicure', 'Basic manicure', 25.00, 30, true)
RETURNING id
`).Scan(&serviceID)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
// Create CONFIRMED booking that started a few minutes ago (still in progress).
var bookingID string
now := clock.Now()
bookingStart := now.Add(-5 * time.Minute) // 5 min ago — within today, started before now, still in progress (30min service)
err = tx.QueryRow(ctx, `
INSERT INTO bookings (user_id, start_time, status, created_at)
VALUES ($1, $2, 'confirmed', NOW())
RETURNING id
`, userID, bookingStart).Scan(&bookingID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
// Add service to booking
_, err = tx.Exec(ctx, `
INSERT INTO booking_services (booking_id, service_id)
VALUES ($1, $2)
`, bookingID, serviceID)
if err != nil {
t.Fatalf("failed to add service to booking: %v", err)
}
// Call GetCurrentAndNextHandler - should trigger auto-transition
handler := http.HandlerFunc(today.GetCurrentAndNextHandler)
w := makeAdminRequest(handler, "GET", "/api/admin/today/current-next", nil, ctx)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
// Verify auto-transition happened
var status string
err = tx.QueryRow(ctx, `
SELECT status FROM bookings WHERE id = $1
`, bookingID).Scan(&status)
if err != nil {
t.Fatalf("failed to query booking status: %v", err)
}
if status != "in_progress" {
t.Errorf("expected 'in_progress' after current-next handler, got '%s'", status)
}
// Verify the response includes the booking as 'current'
var response today.CurrentNextResponse
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if response.Current == nil {
t.Error("expected current booking in response")
} else if response.Current.ID != bookingID {
t.Errorf("expected current booking ID %s, got %s", bookingID, response.Current.ID)
}
}
// =============================================================================
// Week Summary Tests — covering "Closed today" and "Tomorrow is closed" states
// =============================================================================
// TestAdminToday_ClosedDay_Summary verifies that on a closed day:
// - done_for_day = true
// - summary_scope = "week" (closed day summary)
// - total_bookings counts non-cancelled bookings, excluding cancelled/no_show
// - The range includes bookings from both the closed day and prior open days
func TestAdminToday_ClosedDay_Summary(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
now := clock.Now()
londonLoc, _ := time.LoadLocation("Europe/London")
londonNow := now.In(londonLoc)
todayStart := time.Date(londonNow.Year(), londonNow.Month(), londonNow.Day(), 0, 0, 0, 0, londonLoc).UTC()
yesterdayStart := todayStart.AddDate(0, 0, -1)
// Create test user
var userID string
err := tx.QueryRow(ctx, `
INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type)
VALUES ('Test', 'User', 'testuser@test.com', '+1234567890', '1990-01-01', 'hash', 'verified_email', 'email')
RETURNING id
`).Scan(&userID)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
// Seed working hours: today is CLOSED, all other days OPEN
// Use London weekday to match GetCurrentAndNextHandler's londonNow-based lookup.
todayDBWeekday := int((londonNow.Weekday() + 6) % 7)
_, err = tx.Exec(ctx, `
INSERT INTO working_hours (weekday, start_time, end_time, is_open)
VALUES ($1, '00:00', '00:00', false)
ON CONFLICT (weekday) DO UPDATE SET start_time = '00:00', end_time = '00:00', is_open = false
`, todayDBWeekday)
if err != nil {
t.Fatalf("failed to seed today as closed: %v", err)
}
// Mark all other weekdays as open
for wd := 0; wd <= 6; wd++ {
if wd != todayDBWeekday {
_, err = 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)
if err != nil {
t.Fatalf("failed to seed weekday %d as open: %v", wd, err)
}
}
}
// Create bookings:
// - Yesterday: 1 completed
// - Today: 2 completed, 1 client_cancelled, 1 no_show
// total_bookings should count: 1 (yesterday) + 2 (today completed) = 3
// NOT counting: client_cancelled, no_show
yesterdayBookings := []struct {
startTime time.Time
status string
}{
{yesterdayStart.Add(9 * time.Hour), "completed"},
}
todayBookings := []struct {
startTime time.Time
status string
}{
{todayStart.Add(9 * time.Hour), "completed"},
{todayStart.Add(10 * time.Hour), "completed"},
{todayStart.Add(11 * time.Hour), "client_cancelled"},
{todayStart.Add(12 * time.Hour), "no_show"},
}
for _, b := range yesterdayBookings {
_, err = tx.Exec(ctx, `
INSERT INTO bookings (user_id, start_time, status, created_at)
VALUES ($1, $2, $3, NOW())
`, userID, b.startTime, b.status)
if err != nil {
t.Fatalf("failed to create yesterday %s booking: %v", b.status, err)
}
}
for _, b := range todayBookings {
_, err = tx.Exec(ctx, `
INSERT INTO bookings (user_id, start_time, status, created_at)
VALUES ($1, $2, $3, NOW())
`, userID, b.startTime, b.status)
if err != nil {
t.Fatalf("failed to create today %s booking: %v", b.status, err)
}
}
handler := http.HandlerFunc(today.GetCurrentAndNextHandler)
w := makeAdminRequest(handler, "GET", "/api/admin/today/current-next", nil, ctx)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var response today.CurrentNextResponse
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if response.DoneForDay == nil || !*response.DoneForDay {
t.Error("expected done_for_day = true on a closed day")
}
if response.Summary == nil {
t.Fatal("expected summary on a closed day")
}
if response.Summary.SummaryScope != "week" {
t.Errorf("expected summary_scope 'week' for closed day, got '%s'", response.Summary.SummaryScope)
}
if response.Summary.TotalBookings != 3 {
t.Errorf("expected total_bookings = 3 (1 yesterday + 2 today completed, excluding cancelled/no_show), got %d", response.Summary.TotalBookings)
}
if response.Summary.CustomersServed != 1 {
t.Errorf("expected customers_served = 1 (all non-cancelled bookings are by same distinct user), got %d", response.Summary.CustomersServed)
}
}
// TestAdminToday_WeekSummary_TomorrowClosed verifies that when today is open
// and all current+next appointments are done, but tomorrow is closed:
// - summary_scope = "day" (today's summary)
// - week_summary is present with summary_scope = "week"
func TestAdminToday_WeekSummary_TomorrowClosed(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
now := clock.Now()
londonLoc, _ := time.LoadLocation("Europe/London")
londonNow := now.In(londonLoc)
todayStart := time.Date(londonNow.Year(), londonNow.Month(), londonNow.Day(), 0, 0, 0, 0, londonLoc).UTC()
// Create test user
var userID string
err := tx.QueryRow(ctx, `
INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type)
VALUES ('Test', 'User', 'testuser@test.com', '+1234567890', '1990-01-01', 'hash', 'verified_email', 'email')
RETURNING id
`).Scan(&userID)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
// Compute weekdays (London-based to match handler behavior)
todayDBWeekday := int((londonNow.Weekday() + 6) % 7)
tomorrowWeekday := (todayDBWeekday + 1) % 7
// Mark today as OPEN, tomorrow as CLOSED
for wd := 0; wd <= 6; wd++ {
isOpen := true
startTime := "09:00"
endTime := "17:00"
if wd == tomorrowWeekday {
isOpen = false
startTime = "00:00"
endTime = "00:00"
}
_, err = tx.Exec(ctx, `
INSERT INTO working_hours (weekday, start_time, end_time, is_open)
VALUES ($1, $2, $3, $4)
ON CONFLICT (weekday) DO UPDATE SET start_time = $2, end_time = $3, is_open = $4
`, wd, startTime, endTime, isOpen)
if err != nil {
t.Fatalf("failed to seed working_hours weekday %d: %v", wd, err)
}
}
// Create a completed booking for today (so we're done-for-day but today is open)
_, err = tx.Exec(ctx, `
INSERT INTO bookings (user_id, start_time, status, created_at)
VALUES ($1, $2, 'completed', NOW())
`, userID, todayStart.Add(9*time.Hour))
if err != nil {
t.Fatalf("failed to create completed booking: %v", err)
}
handler := http.HandlerFunc(today.GetCurrentAndNextHandler)
w := makeAdminRequest(handler, "GET", "/api/admin/today/current-next", nil, ctx)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var response today.CurrentNextResponse
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if response.DoneForDay == nil || !*response.DoneForDay {
t.Error("expected done_for_day = true (no upcoming bookings)")
}
if response.Summary == nil {
t.Fatal("expected daily summary")
}
if response.Summary.SummaryScope != "day" {
t.Errorf("expected summary_scope 'day' for open day, got '%s'", response.Summary.SummaryScope)
}
if response.WeekSummary == nil {
t.Fatal("expected week_summary when tomorrow is closed")
}
if response.WeekSummary.SummaryScope != "week" {
t.Errorf("expected week_summary summary_scope 'week', got '%s'", response.WeekSummary.SummaryScope)
}
}
// TestAdminToday_ExceptionalHours_ClosedDay verifies that exceptional hours
// (via exceptional_working_hours + groups + applications) correctly make today
// a closed day, even when default working_hours says today is open.
// This tests the column name fix: monday_week_start → week_start.
func TestAdminToday_ExceptionalHours_ClosedDay(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
now := clock.Now()
londonLoc, _ := time.LoadLocation("Europe/London")
londonNow := now.In(londonLoc)
todayStart := time.Date(londonNow.Year(), londonNow.Month(), londonNow.Day(), 0, 0, 0, 0, londonLoc).UTC()
// Compute today's weekday (our system: 0=Monday, 6=Sunday) using London time
todayDBWeekday := int((londonNow.Weekday() + 6) % 7)
// Seed DEFAULT working_hours: today is OPEN (this should be overridden by exceptional hours)
_, err := 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
`, todayDBWeekday)
if err != nil {
t.Fatalf("failed to seed default working_hours: %v", err)
}
// Make all other weekdays open too
for wd := 0; wd <= 6; wd++ {
if wd != todayDBWeekday {
_, err = 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)
if err != nil {
t.Fatalf("failed to seed default working_hours weekday %d: %v", wd, err)
}
}
}
// Now seed EXCEPTIONAL hours making today CLOSED.
// Need: group → hours → application with week_start = Monday of this week
weekday := now.Weekday()
daysSinceMonday := int(weekday) - 1
if daysSinceMonday < 0 {
daysSinceMonday = 6
}
monday := now.AddDate(0, 0, -daysSinceMonday)
mondayStr := monday.Format("2006-01-02")
var groupID int
err = tx.QueryRow(ctx, `
INSERT INTO exceptional_working_hours_groups (name, description)
VALUES ('Test Closure', 'Exceptional closure for test')
RETURNING id
`).Scan(&groupID)
if err != nil {
t.Fatalf("failed to create exceptional hours group: %v", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO exceptional_working_hours (group_id, weekday, start_time, end_time, is_open)
VALUES ($1, $2, '00:00', '00:00', false)
`, groupID, todayDBWeekday)
if err != nil {
t.Fatalf("failed to seed exceptional hours: %v", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO exceptional_group_applications (group_id, week_start)
VALUES ($1, $2::date)
`, groupID, mondayStr)
if err != nil {
t.Fatalf("failed to seed exceptional group application: %v", err)
}
// Create a completed booking on today (to populate summary)
var userID string
err = tx.QueryRow(ctx, `
INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type)
VALUES ('Test', 'User', 'testuser@test.com', '+1234567890', '1990-01-01', 'hash', 'verified_email', 'email')
RETURNING id
`).Scan(&userID)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO bookings (user_id, start_time, status, created_at)
VALUES ($1, $2, 'completed', NOW())
`, userID, todayStart.Add(9*time.Hour))
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
// Call the handler — with exceptional hours making today closed,
// it should use the closed-day branch (summary_scope = "week")
handler := http.HandlerFunc(today.GetCurrentAndNextHandler)
w := makeAdminRequest(handler, "GET", "/api/admin/today/current-next", nil, ctx)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var response today.CurrentNextResponse
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if response.DoneForDay == nil || !*response.DoneForDay {
t.Error("expected done_for_day = true (exceptional closed day)")
}
if response.Summary == nil {
t.Fatal("expected summary on exceptional closed day")
}
// summary_scope should be "week" because today is a closed day via exceptional hours
if response.Summary.SummaryScope != "week" {
t.Errorf("expected summary_scope 'week' for exceptional closed day, got '%s'", response.Summary.SummaryScope)
}
if response.ClosingTime != nil {
t.Errorf("expected no closing_time on closed day, got '%s'", *response.ClosingTime)
}
if response.Summary.TotalBookings != 1 {
t.Errorf("expected total_bookings = 1 (completed booking), got %d", response.Summary.TotalBookings)
}
}
// TestAdminNotifications_List is skipped (WIP) - tests that an admin
// can list all their notifications.
func TestAdminNotifications_List(t *testing.T) {
t.Skip("Skipping - WIP handler")
}
// TestAdminNotifications_Acknowledge is skipped (WIP) - tests that an
// admin can acknowledge a notification.
func TestAdminNotifications_Acknowledge(t *testing.T) {
t.Skip("Skipping - WIP handler")
}
// TestAdminToday_NonAdmin verifies that non-admin users receive HTTP 403
// when accessing today's dashboard endpoints.
func TestAdminToday_NonAdmin(t *testing.T) {
ctx, _ := testutils.SetupTestTx(t)
// Test current-next endpoint
currentNextHandler := mw.RequireAdmin(http.HandlerFunc(today.GetCurrentAndNextHandler))
w := makeUserRequest(currentNextHandler, "GET", "/api/admin/today/current-next", nil, ctx)
if w.Code != http.StatusForbidden {
t.Errorf("CurrentNext: expected status 403, got %d", w.Code)
}
// Test appointments endpoint
appointmentsHandler := mw.RequireAdmin(http.HandlerFunc(today.GetTodayAppointmentsHandler))
w = makeUserRequest(appointmentsHandler, "GET", "/api/admin/today/appointments", nil, ctx)
if w.Code != http.StatusForbidden {
t.Errorf("Appointments: expected status 403, got %d", w.Code)
}
// Test pending-approvals endpoint
pendingApprovalsHandler := mw.RequireAdmin(http.HandlerFunc(today.GetPendingApprovalsHandler))
w = makeUserRequest(pendingApprovalsHandler, "GET", "/api/admin/today/pending-approvals", nil, ctx)
if w.Code != http.StatusForbidden {
t.Errorf("PendingApprovals: expected status 403, got %d", w.Code)
}
// Test notifications list endpoint
notificationsHandler := mw.RequireAdmin(http.HandlerFunc(notifications.GetNotifications))
w = makeUserRequest(notificationsHandler, "GET", "/api/admin/notifications", nil, ctx)
if w.Code != http.StatusForbidden {
t.Errorf("Notifications List: expected status 403, got %d", w.Code)
}
// Test notifications acknowledge endpoint
ackHandler := mw.RequireAdmin(http.HandlerFunc(notifications.AcknowledgeNotification))
w = makeUserRequest(ackHandler, "POST", "/api/admin/notifications/1/acknowledge", nil, ctx)
if w.Code != http.StatusForbidden {
t.Errorf("Notifications Acknowledge: expected status 403, got %d", w.Code)
}
}