test: add coverage tests across backend + fix mock for PENDING checkout support
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

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)
This commit is contained in:
2026-07-10 18:13:44 +01:00
parent c0442d4ebd
commit 3029fd5179
57 changed files with 12604 additions and 94 deletions
@@ -0,0 +1,556 @@
//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())
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,495 @@
//go:build test && dev
package bookings
// Package bookings contains targeted coverage improvements for under-tested
// booking handlers and helpers.
//
// Targets:
// - AdminCreateBookingForUserHandler: override service not in booking, custom
// override not in booking
// - AdminRejectEditRequestHandler: invalid request ID edge case
// - GetOverlappingBookingsHandler: no overlaps, non-existent booking, invalid ID
// - calculateServiceDurationWithOverrides: direct unit tests
import (
"context"
"net/http"
"testing"
"time"
"crussell/db"
"crussell/mw"
"crussell/testutils"
"crussell/testutils/fixtures"
)
// =============================================================================
// AdminCreateBookingForUserHandler — additional coverage
// =============================================================================
// TestAdminCreateBookingForUserHandler_OverrideServiceNotInBooking verifies
// that providing an override for a regular service that was NOT added to the
// booking returns 400 Bad Request.
func TestAdminCreateBookingForUserHandler_OverrideServiceNotInBooking(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)
}
_, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set deposits_required: %v", err)
}
serviceA, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service A: %v", err)
}
serviceB, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service B: %v", err)
}
overridePrice := 75.0
body := AdminCreateBookingForUserRequest{
UserID: userID,
StartTime: weekdayTime(time.Wednesday, 10),
ServiceIDs: []string{serviceA},
// Override references serviceB which is NOT in ServiceIDs
ServiceOverrides: []ServiceOverride{
{ServiceID: serviceB, OverridePrice: &overridePrice},
},
}
w := serveChiHandler(AdminCreateBookingForUserHandler, "POST", "/", "/", body,
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))
})
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400 for override not in booking, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestAdminCreateBookingForUserHandler_CustomOverrideNotInBooking verifies that
// providing an override for a custom service not in the booking returns 400.
func TestAdminCreateBookingForUserHandler_CustomOverrideNotInBooking(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)
}
_, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set deposits_required: %v", err)
}
// Insert a custom service
var customSvcID string
err = tx.QueryRow(ctx, `
INSERT INTO custom_services (name, description, price, duration_minutes)
VALUES ($1, $2, $3, $4)
RETURNING id
`, "Test Custom", "Custom service", 60.00, 45).Scan(&customSvcID)
if err != nil {
t.Fatalf("failed to create custom service: %v", err)
}
// Another custom service that is NOT included in the booking
var otherCustomSvcID string
err = tx.QueryRow(ctx, `
INSERT INTO custom_services (name, description, price, duration_minutes)
VALUES ($1, $2, $3, $4)
RETURNING id
`, "Other Custom", "Not in booking", 40.00, 30).Scan(&otherCustomSvcID)
if err != nil {
t.Fatalf("failed to create other custom service: %v", err)
}
overridePrice := 50.0
body := AdminCreateBookingForUserRequest{
UserID: userID,
StartTime: weekdayTime(time.Wednesday, 10),
CustomServiceIDs: []string{customSvcID},
// Override references otherCustomSvcID which is NOT in CustomServiceIDs
CustomOverrides: []ServiceOverride{
{ServiceID: otherCustomSvcID, OverridePrice: &overridePrice},
},
}
w := serveChiHandler(AdminCreateBookingForUserHandler, "POST", "/", "/", body,
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))
})
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400 for custom override not in booking, got %d. body: %s", w.Code, w.Body.String())
}
}
// =============================================================================
// AdminRejectEditRequestHandler — additional coverage
// =============================================================================
// TestAdminRejectEditRequestHandler_InvalidRequestID verifies that rejecting
// an edit request with an invalid (non-hex) request ID returns 404.
func TestAdminRejectEditRequestHandler_InvalidRequestID(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
// Route: /api/admin/bookings/{id}/edit-requests/{request_id}/deny
// Use a booking ID that passes IsValidID but a request_id that does not
bookingID := "aaaaaaaaaaaa" // valid 12-char hex
invalidRequestID := "not-a-valid-id"
w := serveChiHandler(AdminRejectEditRequestHandler, "POST",
"/api/admin/bookings/"+bookingID+"/edit-requests/"+invalidRequestID+"/deny",
"/api/admin/bookings/{id}/edit-requests/{request_id}/deny", nil,
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))
})
if w.Code != http.StatusNotFound {
t.Errorf("expected 404 for invalid request ID, got %d. body: %s", w.Code, w.Body.String())
}
}
// =============================================================================
// GetOverlappingBookingsHandler — additional coverage
// =============================================================================
// TestAdminGetOverlappingBookingsHandler_NoOverlaps verifies that when a booking
// has no overlapping bookings, an empty list is returned.
func TestAdminGetOverlappingBookingsHandler_NoOverlaps(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 single confirmed booking with no other bookings near it
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)
}
// Query overlapping for the booking — there are no other bookings
w := serveChiHandler(GetOverlappingBookingsHandler, "GET",
"/"+bookingID+"/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 := parseResponseBody(w, &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if len(resp.Bookings) != 0 {
t.Errorf("expected 0 overlapping bookings, got %d", len(resp.Bookings))
}
}
// TestAdminGetOverlappingBookingsHandler_NonExistentBooking verifies that
// querying overlapping bookings for a non-existent booking returns 404.
func TestAdminGetOverlappingBookingsHandler_NonExistentBooking(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
// Use a valid 12-char hex ID that does not exist in the DB
nonExistentID := "aaaaaaaaaaaa"
w := serveChiHandler(GetOverlappingBookingsHandler, "GET",
"/"+nonExistentID+"/overlapping", "/{id}/overlapping", nil,
adminCtx(adminID, ctx), ctx)
if w.Code != http.StatusNotFound {
t.Errorf("expected 404 for non-existent booking, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestAdminGetOverlappingBookingsHandler_InvalidBookingID verifies that
// querying overlapping bookings with an invalid (non-hex) booking ID returns 404.
func TestAdminGetOverlappingBookingsHandler_InvalidBookingID(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
// Use an invalid ID that fails IsValidID
invalidID := "not-valid"
w := serveChiHandler(GetOverlappingBookingsHandler, "GET",
"/"+invalidID+"/overlapping", "/{id}/overlapping", nil,
adminCtx(adminID, ctx), ctx)
if w.Code != http.StatusNotFound {
t.Errorf("expected 404 for invalid booking ID, got %d. body: %s", w.Code, w.Body.String())
}
}
// =============================================================================
// calculateServiceDurationWithOverrides — direct unit tests
// =============================================================================
// TestCalculateServiceDurationWithOverrides_Normal verifies that the total
// duration is the sum of all service durations when no overrides are provided.
func TestCalculateServiceDurationWithOverrides_Normal(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
svc1, err := fixtures.CreateTestServiceWithDuration(tx, 60)
if err != nil {
t.Fatalf("failed to create service 1: %v", err)
}
svc2, err := fixtures.CreateTestServiceWithDuration(tx, 30)
if err != nil {
t.Fatalf("failed to create service 2: %v", err)
}
// Create a context with the tx so db.Conn routes through it
txCtx := db.ContextWithTx(context.Background(), db.TxFromContext(ctx))
dur, err := calculateServiceDurationWithOverrides(txCtx, []string{svc1, svc2}, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if dur != 90 {
t.Errorf("expected duration 90 (60+30), got %d", dur)
}
}
// TestCalculateServiceDurationWithOverrides_WithOverride verifies that when an
// override duration is provided for a regular service, the override is used
// instead of the service's default duration.
func TestCalculateServiceDurationWithOverrides_WithOverride(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
svc1, err := fixtures.CreateTestServiceWithDuration(tx, 60)
if err != nil {
t.Fatalf("failed to create service 1: %v", err)
}
svc2, err := fixtures.CreateTestServiceWithDuration(tx, 30)
if err != nil {
t.Fatalf("failed to create service 2: %v", err)
}
overrideDur := 45
overrides := []ServiceOverrideRequest{
{ServiceID: svc1, OverrideDurationMinutes: &overrideDur},
}
txCtx := db.ContextWithTx(context.Background(), db.TxFromContext(ctx))
dur, err := calculateServiceDurationWithOverrides(txCtx, []string{svc1, svc2}, overrides)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// svc1 overridden to 45, svc2 stays at 30 => total 75
if dur != 75 {
t.Errorf("expected duration 75 (45+30), got %d", dur)
}
}
// TestCalculateServiceDurationWithOverrides_CustomService verifies that custom
// services are included in the duration calculation and overrides work for them.
func TestCalculateServiceDurationWithOverrides_CustomService(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
svcID, err := fixtures.CreateTestServiceWithDuration(tx, 60)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
// Create a custom service directly
var customSvcID string
err = tx.QueryRow(ctx, `
INSERT INTO custom_services (name, description, price, duration_minutes)
VALUES ($1, $2, $3, $4)
RETURNING id
`, "Test Custom Svc", "Custom for duration test", 50.00, 45).Scan(&customSvcID)
if err != nil {
t.Fatalf("failed to create custom service: %v", err)
}
txCtx := db.ContextWithTx(context.Background(), db.TxFromContext(ctx))
dur, err := calculateServiceDurationWithOverrides(txCtx, []string{svcID, customSvcID}, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// svc: 60, custom: 45 => total 105
if dur != 105 {
t.Errorf("expected duration 105 (60+45), got %d", dur)
}
// Now test with override on the custom service
overrideDur := 30
overrides := []ServiceOverrideRequest{
{ServiceID: customSvcID, OverrideDurationMinutes: &overrideDur},
}
dur, err = calculateServiceDurationWithOverrides(txCtx, []string{svcID, customSvcID}, overrides)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// svc: 60, custom overridden to 30 => total 90
if dur != 90 {
t.Errorf("expected duration 90 (60+30), got %d", dur)
}
}
// TestCalculateServiceDurationWithOverrides_MixedOverrides verifies that when
// some services have overrides and others don't, the correct total is computed.
func TestCalculateServiceDurationWithOverrides_MixedOverrides(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
svc1, err := fixtures.CreateTestServiceWithDuration(tx, 60)
if err != nil {
t.Fatalf("failed to create service 1: %v", err)
}
svc2, err := fixtures.CreateTestServiceWithDuration(tx, 30)
if err != nil {
t.Fatalf("failed to create service 2: %v", err)
}
svc3, err := fixtures.CreateTestServiceWithDuration(tx, 90)
if err != nil {
t.Fatalf("failed to create service 3: %v", err)
}
// Override svc1 to 45 and svc3 to 60; svc2 stays at 30
override1 := 45
override3 := 60
overrides := []ServiceOverrideRequest{
{ServiceID: svc1, OverrideDurationMinutes: &override1},
{ServiceID: svc3, OverrideDurationMinutes: &override3},
}
txCtx := db.ContextWithTx(context.Background(), db.TxFromContext(ctx))
dur, err := calculateServiceDurationWithOverrides(txCtx, []string{svc1, svc2, svc3}, overrides)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// svc1: 45, svc2: 30, svc3: 60 => total 135
if dur != 135 {
t.Errorf("expected duration 135 (45+30+60), got %d", dur)
}
}
// TestCalculateServiceDurationWithOverrides_SingleService verifies that a single
// service with no overrides returns its own duration.
func TestCalculateServiceDurationWithOverrides_SingleService(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
svcID, err := fixtures.CreateTestServiceWithDuration(tx, 45)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
txCtx := db.ContextWithTx(context.Background(), db.TxFromContext(ctx))
dur, err := calculateServiceDurationWithOverrides(txCtx, []string{svcID}, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if dur != 45 {
t.Errorf("expected duration 45, got %d", dur)
}
}
// TestCalculateServiceDurationWithOverrides_EmptyServices verifies that an
// empty service list returns 0 duration.
func TestCalculateServiceDurationWithOverrides_EmptyServices(t *testing.T) {
ctx, _ := testutils.SetupTestTx(t)
txCtx := db.ContextWithTx(context.Background(), db.TxFromContext(ctx))
dur, err := calculateServiceDurationWithOverrides(txCtx, []string{}, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if dur != 0 {
t.Errorf("expected duration 0 for empty services, got %d", dur)
}
}
// TestCalculateServiceDurationWithOverrides_NilOverrides verifies that nil
// overrides (when len(overrides)==0) goes through the sum path correctly.
func TestCalculateServiceDurationWithOverrides_NilOverrides(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
svcID, err := fixtures.CreateTestServiceWithDuration(tx, 60)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
txCtx := db.ContextWithTx(context.Background(), db.TxFromContext(ctx))
// Pass nil overrides explicitly
dur, err := calculateServiceDurationWithOverrides(txCtx, []string{svcID}, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if dur != 60 {
t.Errorf("expected duration 60, got %d", dur)
}
}
// TestCalculateServiceDurationWithOverrides_NonExistentService verifies that
// passing a non-existent service ID returns 0 duration (the SUM will be over
// empty rows).
func TestCalculateServiceDurationWithOverrides_NonExistentService(t *testing.T) {
ctx, _ := testutils.SetupTestTx(t)
txCtx := db.ContextWithTx(context.Background(), db.TxFromContext(ctx))
// A valid hex ID that doesn't exist as a service or custom service
dur, err := calculateServiceDurationWithOverrides(txCtx, []string{"aaaaaaaaaaaa"}, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if dur != 0 {
t.Errorf("expected duration 0 for non-existent service, got %d", dur)
}
}
-10
View File
@@ -305,16 +305,6 @@ func AdminCancelBookingHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// AdminListPendingBookingsHandler returns all bookings with status `pending` by delegating to the existing admin list handler.
func AdminListPendingBookingsHandler(w http.ResponseWriter, r *http.Request) {
r = r.Clone(r.Context())
q := r.URL.Query()
q.Set("status", "pending")
r.URL.RawQuery = q.Encode()
GetAllAdminBookingsHandler(w, r)
}
// AdminGetInProgressBookingHandler returns the booking that is currently in progress.
// It joins the bookings table with users to populate the UserSummary in the returned Booking.
func AdminGetInProgressBookingHandler(w http.ResponseWriter, r *http.Request) {
+102
View File
@@ -0,0 +1,102 @@
//go:build test && dev
package bookings
import (
"testing"
"crussell/testutils"
"crussell/testutils/fixtures"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetBookingStatus(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_ = tx
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
svcID, err := fixtures.CreateTestService(tx)
require.NoError(t, err)
bookingID, err := fixtures.CreateTestBooking(tx, userID, svcID)
require.NoError(t, err)
status, err := GetBookingStatus(ctx, bookingID)
require.NoError(t, err)
assert.Equal(t, "pending", status)
_, err = GetBookingStatus(ctx, "nonexistent-id")
assert.Error(t, err)
}
func TestGetBookingStartTime(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_ = tx
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
svcID, err := fixtures.CreateTestService(tx)
require.NoError(t, err)
bookingID, err := fixtures.CreateTestBooking(tx, userID, svcID)
require.NoError(t, err)
startTime, err := GetBookingStartTime(ctx, bookingID)
require.NoError(t, err)
assert.False(t, startTime.IsZero())
_, err = GetBookingStartTime(ctx, "nonexistent-id")
assert.Error(t, err)
}
func TestBookingExists(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_ = tx
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
svcID, err := fixtures.CreateTestService(tx)
require.NoError(t, err)
bookingID, err := fixtures.CreateTestBooking(tx, userID, svcID)
require.NoError(t, err)
exists, err := BookingExists(ctx, bookingID)
require.NoError(t, err)
assert.True(t, exists)
exists, err = BookingExists(ctx, "nonexistent-id")
require.NoError(t, err)
assert.False(t, exists)
}
func TestCountUserBookingsInStatus(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_ = tx
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
svcID, err := fixtures.CreateTestService(tx)
require.NoError(t, err)
_, err = fixtures.CreateTestBooking(tx, userID, svcID)
require.NoError(t, err)
_, err = fixtures.CreateTestBooking(tx, userID, svcID)
require.NoError(t, err)
count, err := CountUserBookingsInStatus(ctx, userID, "pending")
require.NoError(t, err)
assert.Equal(t, 2, count)
count, err = CountUserBookingsInStatus(ctx, userID, "confirmed")
require.NoError(t, err)
assert.Equal(t, 0, count)
}