New tests: guest user creation (success, duplicate email, registered collision), guest booking flow (success, missing user_id, non-guest user_id, deposit bypass), reservation lifecycle (logged-in, anonymous, replace, validation, conflict detection, dual cleanup), anonymization (6mo threshold, pending exclusion). Fixes: deposit advance rule 48h→24h (stale test), time-based test flakiness (2h→72h offsets), handler confusion in no-show tests, enum type casting for booking status. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
389 lines
12 KiB
Go
389 lines
12 KiB
Go
//go:build test
|
|
// +build test
|
|
|
|
package bookings
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"crussell/db"
|
|
"crussell/handlers/scheduling"
|
|
"crussell/mw"
|
|
"crussell/testutils/fixtures"
|
|
"crussell/testutils/jwt"
|
|
"crussell/testutils/testdb"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/go-chi/chi/v5/middleware"
|
|
)
|
|
|
|
func setupReserveTestDB(t *testing.T) func() {
|
|
t.Helper()
|
|
|
|
pool := testdb.Pool(t)
|
|
testdb.Migrate(t, pool)
|
|
testdb.TruncateTables(t, pool)
|
|
|
|
originalDB := db.DB
|
|
db.DB = pool
|
|
|
|
jwt.Init()
|
|
|
|
seedDefaultWorkingHours(t)
|
|
|
|
return func() {
|
|
db.DB = originalDB
|
|
pool.Close()
|
|
}
|
|
}
|
|
|
|
func makeReserveRequest(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.RealIP)
|
|
|
|
if token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
|
|
// Set user context from token if present
|
|
if token != "" {
|
|
if info := extractUserFromTestJWT(token); info != nil {
|
|
rctx := chi.NewRouteContext()
|
|
ctx := context.WithValue(req.Context(), 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) {
|
|
cleanup := setupReserveTestDB(t)
|
|
defer cleanup()
|
|
|
|
userID, _ := fixtures.CreateTestUser(db.DB)
|
|
token := reserveTestToken(t, userID, "verified_email")
|
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
|
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("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) {
|
|
cleanup := setupReserveTestDB(t)
|
|
defer cleanup()
|
|
|
|
userID, _ := fixtures.CreateTestUser(db.DB)
|
|
token := reserveTestToken(t, userID, "verified_email")
|
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
|
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("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("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 = db.DB.QueryRow(context.Background(), `
|
|
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) {
|
|
cleanup := setupReserveTestDB(t)
|
|
defer cleanup()
|
|
|
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
|
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("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) {
|
|
cleanup := setupReserveTestDB(t)
|
|
defer cleanup()
|
|
|
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test service: %v", err)
|
|
}
|
|
serviceIDs := []string{serviceID}
|
|
|
|
// Missing start_time
|
|
w := makeReserveRequest("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("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("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) {
|
|
cleanup := setupReserveTestDB(t)
|
|
defer cleanup()
|
|
|
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
|
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(db.DB)
|
|
bookingStart := time.Now().Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour)
|
|
_, err = db.DB.Exec(context.Background(), `
|
|
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("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) {
|
|
cleanup := setupReserveTestDB(t)
|
|
defer cleanup()
|
|
|
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
|
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 = db.DB.Exec(context.Background(), `
|
|
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("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) {
|
|
cleanup := setupReserveTestDB(t)
|
|
defer cleanup()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Create fixture users for user reservations
|
|
user1ID, _ := fixtures.CreateTestUser(db.DB)
|
|
user2ID, _ := fixtures.CreateTestUser(db.DB)
|
|
|
|
// Create old anon reservation (15 min ago)
|
|
_, err := db.DB.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 = db.DB.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 = db.DB.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 = db.DB.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
|
|
db.DB.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
|
|
db.DB.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
|
|
db.DB.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
|
|
db.DB.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")
|
|
}
|
|
}
|