//go:build test && dev // +build test,dev package payments import ( "context" "net/http" "net/http/httptest" "testing" "time" "crussell/db" "crussell/mw" "crussell/testutils/fixtures" "crussell/testutils/jwt" "github.com/go-chi/chi/v5" ) // ============================================================================= // Unit tests — IsValidBookingStatusForPayment (pure function, no DB) // ============================================================================= func TestIsValidBookingStatusForPayment_Confirmed(t *testing.T) { if !IsValidBookingStatusForPayment("confirmed") { t.Error("expected 'confirmed' to be valid for payment") } } func TestIsValidBookingStatusForPayment_Pending(t *testing.T) { if !IsValidBookingStatusForPayment("pending") { t.Error("expected 'pending' to be valid for payment") } } func TestIsValidBookingStatusForPayment_PendingRelease(t *testing.T) { if !IsValidBookingStatusForPayment("pending_release") { t.Error("expected 'pending_release' to be valid for payment") } } func TestIsValidBookingStatusForPayment_InProgress(t *testing.T) { if !IsValidBookingStatusForPayment("in_progress") { t.Error("expected 'in_progress' to be valid for payment") } } func TestIsValidBookingStatusForPayment_RejectsDepositLapsed(t *testing.T) { if IsValidBookingStatusForPayment("deposit_lapsed") { t.Error("expected 'deposit_lapsed' to be rejected for payment") } } func TestIsValidBookingStatusForPayment_RejectsClientCancelled(t *testing.T) { if IsValidBookingStatusForPayment("client_cancelled") { t.Error("expected 'client_cancelled' to be rejected for payment") } } func TestIsValidBookingStatusForPayment_RejectsWeCancelled(t *testing.T) { if IsValidBookingStatusForPayment("we_cancelled") { t.Error("expected 'we_cancelled' to be rejected for payment") } } func TestIsValidBookingStatusForPayment_RejectsCompleted(t *testing.T) { if IsValidBookingStatusForPayment("completed") { t.Error("expected 'completed' to be rejected for payment") } } func TestIsValidBookingStatusForPayment_RejectsNoShow(t *testing.T) { if IsValidBookingStatusForPayment("no_show") { t.Error("expected 'no_show' to be rejected for payment") } } // ============================================================================= // Integration tests — CreateBookingPayment status guard // ============================================================================= // setupPaymentStatusTest creates a user, service, and booking with the given // status, returning the userID, bookingID, and user token. func setupPaymentStatusTest(t *testing.T, status string) (string, string, string) { t.Helper() userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } // Use a far-future date so the booking is never in the cleanup window. bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) if err != nil { t.Fatalf("failed to create test booking: %v", err) } _, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = $1 WHERE id = $2", status, bookingID) if err != nil { t.Fatalf("failed to set booking status to %q: %v", status, err) } userToken := jwt.GenerateUserToken(userID) return userID, bookingID, userToken } func TestCreateBookingPayment_AcceptsPendingRelease(t *testing.T) { resetTestData(t) _, bookingID, userToken := setupPaymentStatusTest(t, "pending_release") cardToken := "cnon:test-card-nonce" req := CreateBookingPaymentRequest{ Amount: 2500, PaymentType: "deposit", NewCardToken: &cardToken, SaveCard: true, IdempotencyKey: "deposit-pending-release-" + bookingID, } handler := CreateBookingPayment w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken) if w.Code != http.StatusOK { t.Errorf("expected status 200 for pending_release booking, got %d. body: %s", w.Code, w.Body.String()) } // Verify the booking was promoted back to confirmed. var status string err := db.DB.QueryRow(context.Background(), "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status) if err != nil { t.Fatalf("failed to query booking status: %v", err) } if status != "confirmed" { t.Errorf("expected booking promoted from pending_release to 'confirmed', got %q", status) } } func TestCreateBookingPayment_ThresholdMet_SmallPaymentPromotes(t *testing.T) { resetTestData(t) _, bookingID, userToken := setupPaymentStatusTest(t, "pending_release") // Pay 15% (£7.50 on a £50 booking) — below 20% threshold. cardToken := "cnon:test-card-nonce" req := CreateBookingPaymentRequest{ Amount: 750, // £7.50 = 15% of £50 PaymentType: "partial", NewCardToken: &cardToken, SaveCard: false, IdempotencyKey: "below-threshold-" + bookingID, } handler := CreateBookingPayment w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } // Payment below 20% threshold — booking should remain pending_release. var status string err := db.DB.QueryRow(context.Background(), "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status) if err != nil { t.Fatalf("failed to query booking: %v", err) } if status != "pending_release" { t.Errorf("expected booking to remain pending_release, got %q", status) } } func TestCreateBookingPayment_ThresholdMet_BalancePaymentPromotes(t *testing.T) { resetTestData(t) _, bookingID, userToken := setupPaymentStatusTest(t, "pending_release") // Pay £25 via "balance" type — still should meet the 20% threshold. cardToken := "cnon:test-card-nonce" req := CreateBookingPaymentRequest{ Amount: 2500, // £25.00 = 50% of £50 PaymentType: "balance", NewCardToken: &cardToken, SaveCard: false, IdempotencyKey: "balance-promotes-" + bookingID, } handler := CreateBookingPayment w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var status string err := db.DB.QueryRow(context.Background(), "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status) if err != nil { t.Fatalf("failed to query booking: %v", err) } if status != "confirmed" { t.Errorf("expected booking promoted to 'confirmed', got %q", status) } } func TestCreateBookingPayment_AcceptsConfirmed(t *testing.T) { resetTestData(t) _, bookingID, userToken := setupPaymentStatusTest(t, "confirmed") cardToken := "cnon:test-card-nonce" req := CreateBookingPaymentRequest{ Amount: 2500, PaymentType: "deposit", NewCardToken: &cardToken, SaveCard: false, IdempotencyKey: "deposit-confirmed-" + bookingID, } handler := CreateBookingPayment w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken) if w.Code != http.StatusOK { t.Errorf("expected status 200 for confirmed booking, got %d. body: %s", w.Code, w.Body.String()) } } func TestCreateBookingPayment_RejectsPending(t *testing.T) { resetTestData(t) _, bookingID, userToken := setupPaymentStatusTest(t, "pending") cardToken := "cnon:test-card-nonce" req := CreateBookingPaymentRequest{ Amount: 2500, PaymentType: "deposit", NewCardToken: &cardToken, SaveCard: false, IdempotencyKey: "deposit-pending-" + bookingID, } handler := CreateBookingPayment w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken) if w.Code != http.StatusConflict { t.Errorf("expected status 409 for pending booking (not yet confirmed), got %d. body: %s", w.Code, w.Body.String()) } } func TestCreateBookingPayment_RejectsDepositLapsed(t *testing.T) { resetTestData(t) _, bookingID, userToken := setupPaymentStatusTest(t, "deposit_lapsed") cardToken := "cnon:test-card-nonce" req := CreateBookingPaymentRequest{ Amount: 2500, PaymentType: "deposit", NewCardToken: &cardToken, SaveCard: false, IdempotencyKey: "reject-lapsed-" + bookingID, } handler := CreateBookingPayment w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken) if w.Code != http.StatusConflict { t.Errorf("expected status 409 for deposit_lapsed booking, got %d. body: %s", w.Code, w.Body.String()) } } func TestCreateBookingPayment_RejectsClientCancelled(t *testing.T) { resetTestData(t) _, bookingID, userToken := setupPaymentStatusTest(t, "client_cancelled") cardToken := "cnon:test-card-nonce" req := CreateBookingPaymentRequest{ Amount: 2500, PaymentType: "deposit", NewCardToken: &cardToken, SaveCard: false, IdempotencyKey: "reject-cancelled-" + bookingID, } handler := CreateBookingPayment w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken) if w.Code != http.StatusConflict { t.Errorf("expected status 409 for client_cancelled booking, got %d. body: %s", w.Code, w.Body.String()) } } // ============================================================================= // Payment Lock Tests // ============================================================================= func paymentLockRequest(method, path string, token string) *httptest.ResponseRecorder { w := httptest.NewRecorder() req := httptest.NewRequest(method, path, nil) req.Header.Set("Authorization", "Bearer "+token) rctx := chi.NewRouteContext() bookingID, _ := extractPaymentIDFromPath(path) rctx.URLParams.Add("id", bookingID) ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx) if token != "" { if info := extractUserFromTestJWT(token); info != nil { ctx = context.WithValue(ctx, mw.UserIDKey, info.userID) ctx = context.WithValue(ctx, mw.UserRoleKey, info.role) } } AcquirePaymentLock(w, req.WithContext(ctx)) return w } func TestAcquirePaymentLock_Confirmed(t *testing.T) { resetTestData(t) _, bookingID, userToken := setupPaymentStatusTest(t, "confirmed") w := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken) if w.Code != http.StatusOK { t.Errorf("expected 200 for confirmed booking, got %d. body: %s", w.Code, w.Body.String()) } } func TestAcquirePaymentLock_InProgress(t *testing.T) { resetTestData(t) _, bookingID, userToken := setupPaymentStatusTest(t, "in_progress") w := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken) if w.Code != http.StatusOK { t.Errorf("expected 200 for in_progress booking, got %d. body: %s", w.Code, w.Body.String()) } } func TestAcquirePaymentLock_PendingRelease(t *testing.T) { resetTestData(t) _, bookingID, userToken := setupPaymentStatusTest(t, "pending_release") w := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken) if w.Code != http.StatusOK { t.Errorf("expected 200 for pending_release booking, got %d. body: %s", w.Code, w.Body.String()) } } func TestAcquirePaymentLock_RejectsDepositLapsed(t *testing.T) { resetTestData(t) _, bookingID, userToken := setupPaymentStatusTest(t, "deposit_lapsed") w := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken) if w.Code != http.StatusConflict { t.Errorf("expected 409 for deposit_lapsed booking, got %d. body: %s", w.Code, w.Body.String()) } } func TestAcquirePaymentLock_RejectsClientCancelled(t *testing.T) { resetTestData(t) _, bookingID, userToken := setupPaymentStatusTest(t, "client_cancelled") w := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken) if w.Code != http.StatusConflict { t.Errorf("expected 409 for client_cancelled booking, got %d. body: %s", w.Code, w.Body.String()) } } func TestAcquirePaymentLock_RejectsWeCancelled(t *testing.T) { resetTestData(t) _, bookingID, userToken := setupPaymentStatusTest(t, "we_cancelled") w := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken) if w.Code != http.StatusConflict { t.Errorf("expected 409 for we_cancelled booking, got %d. body: %s", w.Code, w.Body.String()) } } func TestAcquirePaymentLock_RejectsNoShow(t *testing.T) { resetTestData(t) _, bookingID, userToken := setupPaymentStatusTest(t, "no_show") w := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken) if w.Code != http.StatusConflict { t.Errorf("expected 409 for no_show booking, got %d. body: %s", w.Code, w.Body.String()) } } func TestAcquirePaymentLock_RejectsPending(t *testing.T) { resetTestData(t) _, bookingID, userToken := setupPaymentStatusTest(t, "pending") w := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken) if w.Code != http.StatusConflict { t.Errorf("expected 409 for pending booking, got %d. body: %s", w.Code, w.Body.String()) } } func TestAcquirePaymentLock_RejectsCompleted(t *testing.T) { resetTestData(t) _, bookingID, userToken := setupPaymentStatusTest(t, "completed") w := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken) if w.Code != http.StatusConflict { t.Errorf("expected 409 for completed booking, got %d. body: %s", w.Code, w.Body.String()) } } // ============================================================================= // ReleasePaymentLock — DELETE /api/bookings/{id}/payment-lock // ============================================================================= func releasePaymentLockRequest(path string, token string) *httptest.ResponseRecorder { w := httptest.NewRecorder() req := httptest.NewRequest("DELETE", path, nil) req.Header.Set("Authorization", "Bearer "+token) rctx := chi.NewRouteContext() bookingID, _ := extractPaymentIDFromPath(path) rctx.URLParams.Add("id", bookingID) ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx) if token != "" { if info := extractUserFromTestJWT(token); info != nil { ctx = context.WithValue(ctx, mw.UserIDKey, info.userID) ctx = context.WithValue(ctx, mw.UserRoleKey, info.role) } } ReleasePaymentLock(w, req.WithContext(ctx)) return w } func TestReleasePaymentLock_HappyPath(t *testing.T) { resetTestData(t) _, bookingID, userToken := setupPaymentStatusTest(t, "confirmed") // First, acquire the lock to create a PAYMENT_IN_FLIGHT time_blocker lockW := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken) if lockW.Code != http.StatusOK { t.Fatalf("expected 200 when acquiring lock, got %d", lockW.Code) } // Verify the lock exists in the DB var lockCount int err := db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM time_blockers WHERE description = 'PAYMENT_IN_FLIGHT:' || $1", bookingID).Scan(&lockCount) if err != nil { t.Fatalf("failed to query time_blockers: %v", err) } if lockCount != 1 { t.Fatalf("expected 1 time_blocker before release, got %d", lockCount) } // Now release the lock w := releasePaymentLockRequest("/api/bookings/"+bookingID+"/payment-lock", userToken) if w.Code != http.StatusNoContent { t.Errorf("expected 204 No Content, got %d. body: %s", w.Code, w.Body.String()) } // Verify the lock was removed from the DB err = db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM time_blockers WHERE description = 'PAYMENT_IN_FLIGHT:' || $1", bookingID).Scan(&lockCount) if err != nil { t.Fatalf("failed to query time_blockers after release: %v", err) } if lockCount != 0 { t.Errorf("expected 0 time_blockers after release, got %d", lockCount) } } func TestReleasePaymentLock_NoExistingLock(t *testing.T) { resetTestData(t) _, bookingID, userToken := setupPaymentStatusTest(t, "confirmed") // Release without acquiring first — should be idempotent (204) w := releasePaymentLockRequest("/api/bookings/"+bookingID+"/payment-lock", userToken) if w.Code != http.StatusNoContent { t.Errorf("expected 204 No Content for idempotent release, got %d. body: %s", w.Code, w.Body.String()) } } func TestReleasePaymentLock_InvalidBookingID(t *testing.T) { w := releasePaymentLockRequest("/api/bookings/invalid/payment-lock", "") if w.Code != http.StatusNotFound { t.Errorf("expected 404 for invalid booking ID, got %d. body: %s", w.Code, w.Body.String()) } } func TestReleasePaymentLock_EmptyBookingID(t *testing.T) { w := releasePaymentLockRequest("/api/bookings//payment-lock", "") if w.Code != http.StatusNotFound { t.Errorf("expected 404 for empty booking ID, got %d. body: %s", w.Code, w.Body.String()) } } func TestReleasePaymentLock_AfterMultipleAcquires(t *testing.T) { resetTestData(t) _, bookingID, userToken := setupPaymentStatusTest(t, "confirmed") // Acquire the lock twice — AcquirePaymentLock should be idempotent lockW := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken) if lockW.Code != http.StatusOK { t.Fatalf("expected 200 on first acquire, got %d", lockW.Code) } lockW = paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken) if lockW.Code != http.StatusOK { t.Fatalf("expected 200 on second acquire, got %d", lockW.Code) } // Release should still succeed w := releasePaymentLockRequest("/api/bookings/"+bookingID+"/payment-lock", userToken) if w.Code != http.StatusNoContent { t.Errorf("expected 204 No Content after multiple acquires, got %d. body: %s", w.Code, w.Body.String()) } // Verify all locks are gone var lockCount int err := db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM time_blockers WHERE description = 'PAYMENT_IN_FLIGHT:' || $1", bookingID).Scan(&lockCount) if err != nil { t.Fatalf("failed to query time_blockers: %v", err) } if lockCount != 0 { t.Errorf("expected 0 time_blockers after release, got %d", lockCount) } }