Files
Crussell/backend/handlers/bookings/admin_bookings_test.go
T
popertots 3029fd5179
CI / Nginx config check (push) Successful in 13s
CI / Env docs check (push) Successful in 15s
CI / Docker compose check (push) Successful in 15s
CI / Frontend major deps (push) Failing after 24s
CI / Frontend deps check (push) Successful in 30s
CI / Secrets scan (push) Successful in 38s
CI / Go build (push) Successful in 39s
CI / Frontend build (push) Successful in 1m3s
CI / Knip (push) Successful in 45s
CI / Go vet (prod) (push) Failing after 1m42s
CI / Frontend a11y check (push) Successful in 2m34s
CI / Go vet (dev) (push) Successful in 2m29s
CI / Staticcheck (prod) (push) Failing after 2m38s
CI / go mod tidy (push) Successful in 1m3s
CI / Staticcheck (dev) (push) Successful in 2m55s
CI / Frontend QC (audit) (push) Successful in 51s
CI / golangci-lint (push) Successful in 3m22s
CI / Go vulnerabilities (push) Successful in 1m26s
CI / Frontend QC (typecheck) (push) Successful in 2m18s
CI / Security scan (prod) (push) Successful in 4m18s
CI / Security scan (dev) (push) Successful in 4m40s
CI / Tests (prod) (push) Has been skipped
CI / Tests (dev) (push) Has been skipped
CI / Race (prod) (push) Has been skipped
CI / Race (dev) (push) Has been skipped
CI / Frontend QC (lint) (push) Successful in 2m18s
CI / Svelte strict check (push) Successful in 43s
test: add coverage tests across backend + fix mock for PENDING checkout support
New test files cover previously untested paths across DAV, validators,
S3, Square, mw, bookings, user, and payments packages.

Includes mock fix: HoldCheckouts flag on MockClient allows tests to
pause auto-complete goroutine for testing PENDING checkout states.

Coverage: 50.4% → 65.0% (+14.6pp)
2026-07-10 18:13:44 +01:00

557 lines
17 KiB
Go

//go:build test && dev
package bookings
// Package bookings tests for admin GET booking endpoints.
//
// Dead-code handlers (NOT registered in main.go routes):
// - queryServiceDetailsByIDs — private helper used by buildEnrichedEditRequest
import (
"context"
"encoding/json"
"net/http"
"testing"
"time"
"crussell/clock"
"crussell/db"
"crussell/mw"
"crussell/testutils"
"crussell/testutils/fixtures"
)
// adminCtx returns a setupCtx that injects admin role + tx into the context.
func adminCtx(adminID string, ctx context.Context) func(context.Context) context.Context {
return func(baseCtx context.Context) context.Context {
baseCtx = context.WithValue(baseCtx, mw.UserRoleKey, "admin")
baseCtx = context.WithValue(baseCtx, mw.UserIDKey, adminID)
return db.ContextWithTx(baseCtx, db.TxFromContext(ctx))
}
}
// =============================================================================
// 1. GetAllAdminBookingsHandler — GET /api/admin/bookings
// =============================================================================
func TestAdminGetAllBookingsHandler_Success(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
w := serveChiHandler(GetAllAdminBookingsHandler, "GET", "/", "/", nil,
adminCtx(adminID, ctx), ctx)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 OK, got %d. body: %s", w.Code, w.Body.String())
}
var resp BookingListResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp.Total < 1 {
t.Errorf("expected at least 1 booking, got total=%d", resp.Total)
}
found := false
for _, b := range resp.Bookings {
if b.ID == bookingID {
found = true
break
}
}
if !found {
t.Errorf("expected booking %s to appear in admin list, but it was not found", bookingID)
}
}
// =============================================================================
// 2. GetAllBookingsByUserHandler — GET /api/admin/bookings/user/{user_id}
// =============================================================================
func TestAdminGetAllBookingsByUserHandler_Success(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
w := serveChiHandler(GetAllBookingsByUserHandler, "GET", "/user/"+userID, "/user/{user_id}", nil,
adminCtx(adminID, ctx), ctx)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 OK, got %d. body: %s", w.Code, w.Body.String())
}
var resp BookingListResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp.Total < 1 {
t.Errorf("expected at least 1 booking for user, got total=%d", resp.Total)
}
// All returned bookings should belong to the user
for _, b := range resp.Bookings {
if b.ID == bookingID {
return // found our booking — success
}
}
t.Errorf("expected booking %s for user %s, but it was not found in results", bookingID, userID)
}
// =============================================================================
// 3. GetAdminBookingHandler — GET /api/admin/bookings/{id}
// =============================================================================
func TestAdminGetBookingHandler_Success(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
w := serveChiHandler(GetAdminBookingHandler, "GET", "/"+bookingID, "/{id}", nil,
adminCtx(adminID, ctx), ctx)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 OK, got %d. body: %s", w.Code, w.Body.String())
}
var booking Booking
if err := json.Unmarshal(w.Body.Bytes(), &booking); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if booking.ID != bookingID {
t.Errorf("expected booking ID %s, got %s", bookingID, booking.ID)
}
if len(booking.Services) == 0 {
t.Error("expected at least 1 service in booking")
}
}
// =============================================================================
// 4. SearchAdminBookingsHandler — GET /api/admin/bookings/search?q=...
// =============================================================================
func TestAdminSearchBookingsHandler_Success(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
// fixture sets notes = 'Test booking'
bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
w := serveChiHandler(SearchAdminBookingsHandler, "GET", "/search?q=Test", "/search", nil,
adminCtx(adminID, ctx), ctx)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 OK, got %d. body: %s", w.Code, w.Body.String())
}
var resp BookingListResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp.Total < 1 {
t.Errorf("expected at least 1 search result for 'Test', got total=%d", resp.Total)
}
found := false
for _, b := range resp.Bookings {
if b.ID == bookingID {
found = true
break
}
}
if !found {
t.Errorf("expected booking %s to appear in search results, but it was not found", bookingID)
}
}
func TestAdminSearchBookingsHandler_MissingQuery_Returns400(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
w := serveChiHandler(SearchAdminBookingsHandler, "GET", "/search", "/search", nil,
adminCtx(adminID, ctx), ctx)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400 for missing query, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestAdminSearchBookingsHandler_LongQuery_Returns400(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
// Build a query > 200 characters
longQuery := ""
for i := 0; i < 210; i++ {
longQuery += "a"
}
w := serveChiHandler(SearchAdminBookingsHandler, "GET", "/search?q="+longQuery, "/search", nil,
adminCtx(adminID, ctx), ctx)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400 for oversized query, got %d. body: %s", w.Code, w.Body.String())
}
}
// =============================================================================
// 5. GetOverlappingBookingsByTimeHandler — GET /api/admin/bookings/overlapping?start=&end=
// =============================================================================
func TestAdminGetOverlappingBookingsByTimeHandler_Success(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
// Create a confirmed booking at a far-future time
baseTime := weekdayTime(time.Wednesday, 10)
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, baseTime)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to confirm booking: %v", err)
}
startStr := baseTime.Add(-1 * time.Hour).Format(time.RFC3339)
endStr := baseTime.Add(2 * time.Hour).Format(time.RFC3339)
w := serveChiHandler(GetOverlappingBookingsByTimeHandler, "GET",
"/overlapping?start="+startStr+"&end="+endStr, "/overlapping", nil,
adminCtx(adminID, ctx), ctx)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 OK, got %d. body: %s", w.Code, w.Body.String())
}
var resp OverlappingBookingsResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
found := false
for _, ob := range resp.Bookings {
if ob.ID == bookingID {
found = true
break
}
}
if !found {
t.Errorf("expected overlapping booking %s to be returned, but it was not found. bookings=%+v", bookingID, resp.Bookings)
}
}
func TestAdminGetOverlappingBookingsByTimeHandler_MissingParams_Returns400(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
w := serveChiHandler(GetOverlappingBookingsByTimeHandler, "GET",
"/overlapping", "/overlapping", nil,
adminCtx(adminID, ctx), ctx)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400 for missing params, got %d. body: %s", w.Code, w.Body.String())
}
}
// =============================================================================
// 6. GetOverlappingBookingsHandler — GET /api/admin/bookings/{id}/overlapping
// =============================================================================
func TestAdminGetOverlappingBookingsHandler_Success(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
dur := durationMinutes(t, ctx, tx, serviceID)
// Create booking A — [10:00, 10:00+dur)
baseTime := weekdayTime(time.Wednesday, 10)
bookingA, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, baseTime)
if err != nil {
t.Fatalf("failed to create booking A: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingA)
if err != nil {
t.Fatalf("failed to confirm booking A: %v", err)
}
// Create booking B that overlaps A — starts dur/2 after A starts
overlapTime := baseTime.Add(time.Duration(dur/2) * time.Minute)
bookingB, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, overlapTime)
if err != nil {
t.Fatalf("failed to create booking B: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingB)
if err != nil {
t.Fatalf("failed to confirm booking B: %v", err)
}
// Query overlapping for booking A — should return booking B
w := serveChiHandler(GetOverlappingBookingsHandler, "GET",
"/"+bookingA+"/overlapping", "/{id}/overlapping", nil,
adminCtx(adminID, ctx), ctx)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 OK, got %d. body: %s", w.Code, w.Body.String())
}
var resp OverlappingBookingsResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if len(resp.Bookings) == 0 {
t.Fatal("expected at least 1 overlapping booking")
}
// Verify booking A is NOT in the result (it's excluded)
for _, ob := range resp.Bookings {
if ob.ID == bookingA {
t.Errorf("expected booking A to be excluded from its own overlapping results")
}
}
// Verify booking B IS in the result
found := false
for _, ob := range resp.Bookings {
if ob.ID == bookingB {
found = true
break
}
}
if !found {
t.Errorf("expected overlapping booking %s to be returned, but it was not found", bookingB)
}
}
// =============================================================================
// 7. GetBookingsByDateRangeHandler — GET /api/admin/bookings/by-date-range?start=&end=
// =============================================================================
func TestAdminGetBookingsByDateRangeHandler_Success(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
// Create a confirmed booking on 2099-12-31 (default fixture)
bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to confirm booking: %v", err)
}
// Query the day before and after
w := serveChiHandler(GetBookingsByDateRangeHandler, "GET",
"/by-date-range?start=2099-12-30&end=2099-12-31", "/by-date-range", nil,
adminCtx(adminID, ctx), ctx)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 OK, got %d. body: %s", w.Code, w.Body.String())
}
var resp OverlappingBookingsResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
found := false
for _, ob := range resp.Bookings {
if ob.ID == bookingID {
found = true
break
}
}
if !found {
t.Errorf("expected booking %s to be returned in date range, but it was not found", bookingID)
}
}
func TestAdminGetBookingsByDateRangeHandler_MissingParams_Returns400(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
w := serveChiHandler(GetBookingsByDateRangeHandler, "GET",
"/by-date-range", "/by-date-range", nil,
adminCtx(adminID, ctx), ctx)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400 for missing params, got %d. body: %s", w.Code, w.Body.String())
}
}
// =============================================================================
// 8. GetBookingsByCreatedRangeHandler — GET /api/admin/bookings/by-created-range?start=&end=
// =============================================================================
func TestAdminGetBookingsByCreatedRangeHandler_Success(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
// Use wide time range around current time to capture the booking's created_at
now := clock.Now()
startStr := now.Add(-24 * time.Hour).Format(time.RFC3339)
endStr := now.Add(24 * time.Hour).Format(time.RFC3339)
w := serveChiHandler(GetBookingsByCreatedRangeHandler, "GET",
"/by-created-range?start="+startStr+"&end="+endStr, "/by-created-range", nil,
adminCtx(adminID, ctx), ctx)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 OK, got %d. body: %s", w.Code, w.Body.String())
}
var resp OverlappingBookingsResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
found := false
for _, ob := range resp.Bookings {
if ob.ID == bookingID {
found = true
break
}
}
if !found {
t.Errorf("expected booking %s to be returned in created range, but it was not found", bookingID)
}
}
func TestAdminGetBookingsByCreatedRangeHandler_MissingParams_Returns400(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
w := serveChiHandler(GetBookingsByCreatedRangeHandler, "GET",
"/by-created-range", "/by-created-range", nil,
adminCtx(adminID, ctx), ctx)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400 for missing params, got %d. body: %s", w.Code, w.Body.String())
}
}