Migrate all test files from SetupTestDB/db.DB pattern to per-test transactions: - Replace SetupTestDB(t) with SetupTestTx(t) for context + transaction - Replace db.DB.Query/QueryRow/Exec with tx.Query/QueryRow/Exec - Replace context.Background() with context from SetupTestTx - Replace defer rows.Close() pattern with explicit rows.Close() - Add testdb.SeedBaseline(pool) to all TestMain functions - Wire db.Conn = db.NewPoolProxy(pool) in all TestMain functions Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
638 lines
20 KiB
Go
638 lines
20 KiB
Go
//go:build test && dev
|
|
// +build test,dev
|
|
|
|
package bookings
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"crussell/db"
|
|
"crussell/handlers/scheduling"
|
|
"crussell/mw"
|
|
"crussell/testutils"
|
|
"crussell/testutils/fixtures"
|
|
"crussell/testutils/jwt"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/go-chi/chi/v5/middleware"
|
|
)
|
|
|
|
func makeReserveRequest(ctx context.Context, method, path string, body interface{}, token string) *httptest.ResponseRecorder {
|
|
var req *http.Request
|
|
if body != nil {
|
|
bodyBytes, _ := json.Marshal(body)
|
|
req = httptest.NewRequest(method, path, bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
} else {
|
|
req = httptest.NewRequest(method, path, nil)
|
|
}
|
|
|
|
// Add chi middleware stack for proper routing context
|
|
r := chi.NewRouter()
|
|
r.Use(middleware.RequestID)
|
|
r.Use(middleware.ClientIPFromRemoteAddr)
|
|
|
|
if token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
|
|
// Set up chi routing context and user context from token if present
|
|
if token != "" {
|
|
if info := extractUserFromTestJWT(token); info != nil {
|
|
rctx := chi.NewRouteContext()
|
|
ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx)
|
|
ctx = context.WithValue(ctx, mw.UserIDKey, info.userID)
|
|
ctx = context.WithValue(ctx, mw.UserRoleKey, info.role)
|
|
}
|
|
}
|
|
req = req.WithContext(ctx)
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
handler := http.HandlerFunc(ReserveSlotHandler)
|
|
r.Post("/api/bookings/reserve", handler)
|
|
r.ServeHTTP(w, req)
|
|
return w
|
|
}
|
|
|
|
func reserveTestToken(t *testing.T, userID, role string) string {
|
|
t.Helper()
|
|
return jwt.GenerateTestToken(userID, role)
|
|
}
|
|
|
|
// TestReserveSlot_LoggedIn verifies logged-in users can reserve a slot.
|
|
func TestReserveSlot_LoggedIn(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, _ := fixtures.CreateTestUser(tx)
|
|
token := reserveTestToken(t, userID, "verified_email")
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test service: %v", err)
|
|
}
|
|
startTime := time.Now().Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour)
|
|
|
|
reqBody := ReserveSlotRequest{
|
|
StartTime: startTime,
|
|
ServiceIDs: []string{serviceID},
|
|
}
|
|
|
|
w := makeReserveRequest(ctx, "POST", "/api/bookings/reserve", reqBody, token)
|
|
|
|
if w.Code != http.StatusCreated {
|
|
t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var response ReserveSlotResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
|
t.Fatalf("failed to unmarshal response: %v", err)
|
|
}
|
|
|
|
if response.IsAnonymous {
|
|
t.Error("expected is_anonymous=false for logged-in user")
|
|
}
|
|
if response.DurationMinutes == 0 {
|
|
t.Error("expected non-zero duration")
|
|
}
|
|
}
|
|
|
|
// TestReserveSlot_LoggedIn_ReplacesExisting verifies creating a second reservation
|
|
// for the same user deletes the first one (max 1 per user).
|
|
func TestReserveSlot_LoggedIn_ReplacesExisting(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, _ := fixtures.CreateTestUser(tx)
|
|
token := reserveTestToken(t, userID, "verified_email")
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test service: %v", err)
|
|
}
|
|
serviceIDs := []string{serviceID}
|
|
|
|
// First reservation
|
|
startTime1 := time.Now().Add(48 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour)
|
|
w1 := makeReserveRequest(ctx, "POST", "/api/bookings/reserve", ReserveSlotRequest{
|
|
StartTime: startTime1,
|
|
ServiceIDs: serviceIDs,
|
|
}, token)
|
|
if w1.Code != http.StatusCreated {
|
|
t.Fatalf("first reservation failed: %d", w1.Code)
|
|
}
|
|
|
|
// Second reservation (should replace first)
|
|
startTime2 := time.Now().Add(72 * time.Hour).Truncate(24 * time.Hour).Add(14 * time.Hour)
|
|
w2 := makeReserveRequest(ctx, "POST", "/api/bookings/reserve", ReserveSlotRequest{
|
|
StartTime: startTime2,
|
|
ServiceIDs: serviceIDs,
|
|
}, token)
|
|
if w2.Code != http.StatusCreated {
|
|
t.Fatalf("second reservation failed: %d", w2.Code)
|
|
}
|
|
|
|
// Verify only one user reservation exists
|
|
var count int
|
|
err = tx.QueryRow(ctx, `
|
|
SELECT COUNT(*) FROM time_blockers
|
|
WHERE description LIKE 'RESERVATION:user:%' AND created_by = $1
|
|
`, userID).Scan(&count)
|
|
if err != nil {
|
|
t.Fatalf("failed to count reservations: %v", err)
|
|
}
|
|
if count != 1 {
|
|
t.Errorf("expected 1 reservation for user, got %d", count)
|
|
}
|
|
}
|
|
|
|
// TestReserveSlot_Anonymous verifies anonymous users can reserve a slot.
|
|
func TestReserveSlot_Anonymous(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test service: %v", err)
|
|
}
|
|
serviceIDs := []string{serviceID}
|
|
startTime := time.Now().Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour)
|
|
|
|
reqBody := ReserveSlotRequest{
|
|
StartTime: startTime,
|
|
ServiceIDs: serviceIDs,
|
|
}
|
|
|
|
w := makeReserveRequest(ctx, "POST", "/api/bookings/reserve", reqBody, "")
|
|
|
|
if w.Code != http.StatusCreated {
|
|
t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var response ReserveSlotResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
|
t.Fatalf("failed to unmarshal response: %v", err)
|
|
}
|
|
|
|
if !response.IsAnonymous {
|
|
t.Error("expected is_anonymous=true for anonymous user")
|
|
}
|
|
}
|
|
|
|
// TestReserveSlot_ValidationErrors verifies that missing or invalid
|
|
// fields result in 400 Bad Request.
|
|
func TestReserveSlot_ValidationErrors(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test service: %v", err)
|
|
}
|
|
serviceIDs := []string{serviceID}
|
|
|
|
// Missing start_time
|
|
w := makeReserveRequest(ctx, "POST", "/api/bookings/reserve", map[string]interface{}{
|
|
"service_ids": serviceIDs,
|
|
}, "")
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400 for missing start_time, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Missing service_ids
|
|
startTime := time.Now().Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour)
|
|
w = makeReserveRequest(ctx, "POST", "/api/bookings/reserve", map[string]interface{}{
|
|
"start_time": startTime,
|
|
}, "")
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400 for missing service_ids, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Past start_time
|
|
w = makeReserveRequest(ctx, "POST", "/api/bookings/reserve", ReserveSlotRequest{
|
|
StartTime: time.Now().Add(-1 * time.Hour),
|
|
ServiceIDs: serviceIDs,
|
|
}, "")
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400 for past start_time, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestReserveSlot_BlockedByExistingBooking verifies that reserving a slot
|
|
// that overlaps an existing booking returns 409 Conflict.
|
|
func TestReserveSlot_BlockedByExistingBooking(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test service: %v", err)
|
|
}
|
|
|
|
// Create a fixture user and booking at the same time
|
|
userID, _ := fixtures.CreateTestUser(tx)
|
|
bookingStart := time.Now().Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour)
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO bookings (user_id, start_time, status, deposit_required)
|
|
VALUES ($1, $2, 'confirmed', false)
|
|
`, userID, bookingStart)
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
w := makeReserveRequest(ctx, "POST", "/api/bookings/reserve", ReserveSlotRequest{
|
|
StartTime: bookingStart,
|
|
ServiceIDs: []string{serviceID},
|
|
}, "")
|
|
if w.Code != http.StatusConflict {
|
|
t.Errorf("expected 409 for overlapping booking, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestReserveSlot_BlockedByTimeBlocker verifies that reserving a blocked
|
|
// time slot returns 409 Conflict.
|
|
func TestReserveSlot_BlockedByTimeBlocker(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test service: %v", err)
|
|
}
|
|
|
|
blockerStart := time.Now().Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour)
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
|
|
VALUES ($1, 60, 'Admin Blocked', NULL)
|
|
`, blockerStart)
|
|
if err != nil {
|
|
t.Fatalf("failed to create blocker: %v", err)
|
|
}
|
|
|
|
w := makeReserveRequest(ctx, "POST", "/api/bookings/reserve", ReserveSlotRequest{
|
|
StartTime: blockerStart,
|
|
ServiceIDs: []string{serviceID},
|
|
}, "")
|
|
if w.Code != http.StatusConflict {
|
|
t.Errorf("expected 409 for blocked slot, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestReserveSlot_DualCleanup verifies that CleanupOldReservations deletes
|
|
// anon reservations after 10 minutes and user reservations after 1 hour.
|
|
func TestReserveSlot_DualCleanup(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
// Create fixture users for user reservations
|
|
user1ID, _ := fixtures.CreateTestUser(tx)
|
|
user2ID, _ := fixtures.CreateTestUser(tx)
|
|
|
|
// Create old anon reservation (15 min ago)
|
|
_, err := tx.Exec(ctx, `
|
|
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at, created_by)
|
|
VALUES ($1, 60, 'RESERVATION:anon:abc12345:1234', $2, NULL)
|
|
`, time.Now().Add(24*time.Hour), time.Now().Add(-15*time.Minute))
|
|
if err != nil {
|
|
t.Fatalf("failed to create old anon reservation: %v", err)
|
|
}
|
|
|
|
// Create recent anon reservation (5 min ago) - should survive
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at, created_by)
|
|
VALUES ($1, 60, 'RESERVATION:anon:def67890:1234', $2, NULL)
|
|
`, time.Now().Add(48*time.Hour), time.Now().Add(-5*time.Minute))
|
|
if err != nil {
|
|
t.Fatalf("failed to create recent anon reservation: %v", err)
|
|
}
|
|
|
|
// Create old user reservation (45 min ago) - should survive (> 10min, < 1hr)
|
|
_, err = tx.Exec(ctx, fmt.Sprintf(`
|
|
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at, created_by)
|
|
VALUES ($1, 60, 'RESERVATION:user:%s:1234', $2, $3)
|
|
`, user1ID), time.Now().Add(72*time.Hour), time.Now().Add(-45*time.Minute), user1ID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create old user reservation: %v", err)
|
|
}
|
|
|
|
// Create very old user reservation (2 hours ago) - should be deleted
|
|
_, err = tx.Exec(ctx, fmt.Sprintf(`
|
|
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at, created_by)
|
|
VALUES ($1, 60, 'RESERVATION:user:%s:1234', $2, $3)
|
|
`, user2ID), time.Now().Add(96*time.Hour), time.Now().Add(-2*time.Hour), user2ID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create very old user reservation: %v", err)
|
|
}
|
|
|
|
// Run cleanup
|
|
err = scheduling.CleanupOldReservations(ctx)
|
|
if err != nil {
|
|
t.Fatalf("CleanupOldReservations failed: %v", err)
|
|
}
|
|
|
|
// Verify old anon was deleted
|
|
var anonOldCount int
|
|
tx.QueryRow(ctx, `SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:anon:abc12345:%'`).Scan(&anonOldCount)
|
|
if anonOldCount > 0 {
|
|
t.Error("expected old anon reservation to be deleted")
|
|
}
|
|
|
|
// Verify recent anon survived
|
|
var anonRecentCount int
|
|
tx.QueryRow(ctx, `SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:anon:def67890:%'`).Scan(&anonRecentCount)
|
|
if anonRecentCount != 1 {
|
|
t.Error("expected recent anon reservation to survive")
|
|
}
|
|
|
|
// Verify 45-min user reservation survived
|
|
var user45Count int
|
|
tx.QueryRow(ctx, `SELECT COUNT(*) FROM time_blockers WHERE description LIKE $1`, fmt.Sprintf("RESERVATION:user:%s:%%", user1ID)).Scan(&user45Count)
|
|
if user45Count != 1 {
|
|
t.Error("expected 45-min user reservation to survive")
|
|
}
|
|
|
|
// Verify 2hr user reservation was deleted
|
|
var user2hrCount int
|
|
tx.QueryRow(ctx, `SELECT COUNT(*) FROM time_blockers WHERE description LIKE $1`, fmt.Sprintf("RESERVATION:user:%s:%%", user2ID)).Scan(&user2hrCount)
|
|
if user2hrCount > 0 {
|
|
t.Error("expected 2-hour user reservation to be deleted")
|
|
}
|
|
}
|
|
|
|
// seedCustomWorkingHours replaces working_hours with the given schedule.
|
|
// DB convention: 0=Monday, 1=Tuesday, ..., 6=Sunday.
|
|
func seedCustomWorkingHours(t *testing.T, ctx context.Context, q db.Querier, hours []struct {
|
|
weekday int
|
|
startTime string
|
|
endTime string
|
|
isOpen bool
|
|
}) {
|
|
t.Helper()
|
|
for _, h := range hours {
|
|
_, err := q.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
|
|
`, h.weekday, h.startTime, h.endTime, h.isOpen)
|
|
if err != nil {
|
|
t.Fatalf("failed to seed working hours for weekday %d: %v", h.weekday, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// nextWeekday returns the next occurrence of the given weekday (0=Sunday..6=Saturday)
|
|
// in the given location, at least 2 days from now to avoid "in the past" rejections.
|
|
func nextWeekday(weekday time.Weekday, loc *time.Location) time.Time {
|
|
now := time.Now().In(loc)
|
|
daysAhead := int(weekday) - int(now.Weekday())
|
|
if daysAhead <= 0 {
|
|
daysAhead += 7
|
|
}
|
|
if daysAhead < 2 {
|
|
daysAhead += 7
|
|
}
|
|
next := now.AddDate(0, 0, daysAhead)
|
|
return time.Date(next.Year(), next.Month(), next.Day(), 0, 0, 0, 0, next.Location())
|
|
}
|
|
|
|
// TestReserveSlot_WeekdayConversion verifies that Go's time.Weekday (0=Sunday)
|
|
// is correctly mapped to the DB's weekday convention (0=Monday).
|
|
func TestReserveSlot_WeekdayConversion(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
hours := []struct {
|
|
weekday int
|
|
startTime string
|
|
endTime string
|
|
isOpen bool
|
|
}{
|
|
{0, "09:00", "17:00", true},
|
|
{1, "09:00", "17:00", true},
|
|
{2, "09:00", "17:00", true},
|
|
{3, "09:00", "17:00", true},
|
|
{4, "09:00", "17:00", true},
|
|
{5, "10:00", "14:00", true},
|
|
{6, "00:00", "00:00", false},
|
|
}
|
|
|
|
seedCustomWorkingHours(t, ctx, tx, hours)
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test service: %v", err)
|
|
}
|
|
|
|
london, err := time.LoadLocation("Europe/London")
|
|
if err != nil {
|
|
t.Fatalf("Europe/London not available: %v", err)
|
|
}
|
|
|
|
monday := nextWeekday(time.Monday, london)
|
|
|
|
tests := []struct {
|
|
name string
|
|
startTime time.Time
|
|
expectCode int
|
|
}{
|
|
{"Monday 10:00", monday.Add(10 * time.Hour), http.StatusCreated},
|
|
{"Tuesday 10:00", monday.AddDate(0, 0, 1).Add(10 * time.Hour), http.StatusCreated},
|
|
{"Wednesday 10:00", monday.AddDate(0, 0, 2).Add(10 * time.Hour), http.StatusCreated},
|
|
{"Thursday 10:00", monday.AddDate(0, 0, 3).Add(10 * time.Hour), http.StatusCreated},
|
|
{"Friday 10:00", monday.AddDate(0, 0, 4).Add(10 * time.Hour), http.StatusCreated},
|
|
{"Saturday 11:00", monday.AddDate(0, 0, 5).Add(11 * time.Hour), http.StatusCreated},
|
|
{"Sunday 11:00", monday.AddDate(0, 0, 6).Add(11 * time.Hour), http.StatusBadRequest},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
w := makeReserveRequest(ctx, "POST", "/api/bookings/reserve", ReserveSlotRequest{
|
|
StartTime: tt.startTime,
|
|
ServiceIDs: []string{serviceID},
|
|
}, "")
|
|
|
|
if w.Code != tt.expectCode {
|
|
t.Errorf("%s: expected status %d, got %d. body: %s", tt.name, tt.expectCode, w.Code, w.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestReserveSlot_ClosingHoursValidation verifies that bookings extending
|
|
// past closing time are rejected, and that the correct day's closing time
|
|
// is used (not the wrong day due to weekday mismatch).
|
|
func TestReserveSlot_ClosingHoursValidation(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
hours := []struct {
|
|
weekday int
|
|
startTime string
|
|
endTime string
|
|
isOpen bool
|
|
}{
|
|
{0, "09:00", "17:00", true},
|
|
{1, "09:00", "17:00", true},
|
|
{2, "09:00", "17:00", true},
|
|
{3, "12:00", "20:00", true},
|
|
{4, "09:00", "17:00", true},
|
|
{5, "10:00", "14:00", true},
|
|
{6, "00:00", "00:00", false},
|
|
}
|
|
|
|
seedCustomWorkingHours(t, ctx, tx, hours)
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test service: %v", err)
|
|
}
|
|
|
|
london, err := time.LoadLocation("Europe/London")
|
|
if err != nil {
|
|
t.Fatalf("Europe/London not available: %v", err)
|
|
}
|
|
|
|
monday := nextWeekday(time.Monday, london)
|
|
thursday := monday.AddDate(0, 0, 3)
|
|
|
|
tests := []struct {
|
|
name string
|
|
startTime time.Time
|
|
expectCode int
|
|
}{
|
|
{"Thursday 17:30 (within 20:00 close)", thursday.Add(17*time.Hour + 30*time.Minute), http.StatusCreated},
|
|
{"Thursday 19:30 (past 20:00 close)", thursday.Add(19*time.Hour + 30*time.Minute), http.StatusBadRequest},
|
|
{"Monday 16:30 (past 17:00 close)", monday.Add(16*time.Hour + 30*time.Minute), http.StatusBadRequest},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
w := makeReserveRequest(ctx, "POST", "/api/bookings/reserve", ReserveSlotRequest{
|
|
StartTime: tt.startTime,
|
|
ServiceIDs: []string{serviceID},
|
|
}, "")
|
|
|
|
if w.Code != tt.expectCode {
|
|
t.Errorf("%s: expected status %d, got %d. body: %s", tt.name, tt.expectCode, w.Code, w.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestReserveSlot_UTCtoLondonConversion verifies that a UTC timestamp sent
|
|
// from the browser is correctly interpreted as London local time for the
|
|
// purpose of working hours lookup.
|
|
func TestReserveSlot_UTCtoLondonConversion(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
hours := []struct {
|
|
weekday int
|
|
startTime string
|
|
endTime string
|
|
isOpen bool
|
|
}{
|
|
{0, "09:00", "17:00", true},
|
|
{1, "09:00", "17:00", true},
|
|
{2, "09:00", "17:00", true},
|
|
{3, "12:00", "20:00", true},
|
|
{4, "09:00", "17:00", true},
|
|
{5, "10:00", "14:00", true},
|
|
{6, "00:00", "00:00", false},
|
|
}
|
|
|
|
seedCustomWorkingHours(t, ctx, tx, hours)
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test service: %v", err)
|
|
}
|
|
|
|
london, err := time.LoadLocation("Europe/London")
|
|
if err != nil {
|
|
t.Fatalf("Europe/London not available: %v", err)
|
|
}
|
|
|
|
thursday := nextWeekday(time.Thursday, london)
|
|
// Convert to UTC for the request (frontend sends UTC)
|
|
thursday1730BST := thursday.Add(17*time.Hour + 30*time.Minute).In(london).UTC()
|
|
thursday1930BST := thursday.Add(19*time.Hour + 30*time.Minute).In(london).UTC()
|
|
|
|
tests := []struct {
|
|
name string
|
|
startTime time.Time
|
|
expectCode int
|
|
}{
|
|
{"17:30 BST Thursday (within 20:00 close)", thursday1730BST, http.StatusCreated},
|
|
{"19:30 BST Thursday (past 20:00 close)", thursday1930BST, http.StatusBadRequest},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
w := makeReserveRequest(ctx, "POST", "/api/bookings/reserve", ReserveSlotRequest{
|
|
StartTime: tt.startTime,
|
|
ServiceIDs: []string{serviceID},
|
|
}, "")
|
|
|
|
if w.Code != tt.expectCode {
|
|
t.Errorf("%s: expected status %d, got %d. body: %s", tt.name, tt.expectCode, w.Code, w.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestReserveSlot_DifferentClosingPerDay verifies that each day's closing
|
|
// time is used independently — a late-booking on a late-closing day should
|
|
// succeed while the same time on an early-closing day should fail.
|
|
func TestReserveSlot_DifferentClosingPerDay(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
hours := []struct {
|
|
weekday int
|
|
startTime string
|
|
endTime string
|
|
isOpen bool
|
|
}{
|
|
{0, "09:00", "17:00", true},
|
|
{1, "09:00", "17:00", true},
|
|
{2, "12:00", "17:00", true},
|
|
{3, "12:00", "20:00", true},
|
|
{4, "09:00", "17:00", true},
|
|
{5, "10:00", "14:00", true},
|
|
{6, "00:00", "00:00", false},
|
|
}
|
|
|
|
seedCustomWorkingHours(t, ctx, tx, hours)
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test service: %v", err)
|
|
}
|
|
|
|
london, err := time.LoadLocation("Europe/London")
|
|
if err != nil {
|
|
t.Fatalf("Europe/London not available: %v", err)
|
|
}
|
|
|
|
wednesday := nextWeekday(time.Wednesday, london)
|
|
thursday := wednesday.AddDate(0, 0, 1)
|
|
|
|
tests := []struct {
|
|
name string
|
|
startTime time.Time
|
|
expectCode int
|
|
}{
|
|
{"Wednesday 18:00 (closes 17:00)", wednesday.Add(18 * time.Hour), http.StatusBadRequest},
|
|
{"Thursday 18:00 (closes 20:00)", thursday.Add(18 * time.Hour), http.StatusCreated},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
w := makeReserveRequest(ctx, "POST", "/api/bookings/reserve", ReserveSlotRequest{
|
|
StartTime: tt.startTime,
|
|
ServiceIDs: []string{serviceID},
|
|
}, "")
|
|
|
|
if w.Code != tt.expectCode {
|
|
t.Errorf("%s: expected status %d, got %d. body: %s", tt.name, tt.expectCode, w.Code, w.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|