Files
Crussell/backend/handlers/payments/payments_test.go
T

1635 lines
42 KiB
Go

//go:build test && dev
// +build test,dev
package payments
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"testing"
"crussell/db"
"crussell/internal/square"
"crussell/mw"
"crussell/testutils/fixtures"
"crussell/testutils/jwt"
"crussell/testutils/testdb"
"github.com/go-chi/chi/v5"
)
func TestMain(m *testing.M) {
pool, err := testdb.NewPool("")
if err != nil {
panic(err)
}
testdb.Migrate(&testing.T{}, pool)
db.DB = pool
jwt.Init()
square.Client = square.NewDevClient()
SquareClient = square.Client
code := m.Run()
pool.Close()
os.Exit(code)
}
func resetTestData(t *testing.T) {
t.Helper()
testdb.TruncateTables(t, db.DB)
}
func makePaymentRequest(handler http.HandlerFunc, method, path string, body interface{}, token string) *httptest.ResponseRecorder {
return makePaymentAuthRequest(handler, method, path, body, token, "")
}
func makePaymentAuthRequest(handler http.HandlerFunc, method, path string, body interface{}, token, userIDOverride 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)
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
rctx := chi.NewRouteContext()
if id, paramName := extractPaymentIDFromPath(path); id != "" {
rctx.URLParams.Add(paramName, id)
}
ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
var userID, userRole string
if userIDOverride != "" {
userID = userIDOverride
userRole = "verified_email"
} else if token != "" {
if info := extractUserFromTestJWT(token); info != nil {
userID = info.userID
userRole = info.role
}
}
if userID != "" {
ctx = context.WithValue(ctx, mw.UserIDKey, userID)
ctx = context.WithValue(ctx, mw.UserRoleKey, userRole)
}
req = req.WithContext(ctx)
w := httptest.NewRecorder()
handler(w, req)
return w
}
type paymentUserInfo struct {
userID string
role string
}
func extractUserFromTestJWT(token string) *paymentUserInfo {
parts := splitToken(token)
if len(parts) != 3 {
return nil
}
decoded, err := base64URLDecode(parts[1])
if err != nil {
return nil
}
var claims map[string]interface{}
if err := json.Unmarshal(decoded, &claims); err != nil {
return nil
}
userID, _ := claims["user_id"].(string)
role, _ := claims["role"].(string)
if userID == "" {
return nil
}
return &paymentUserInfo{userID: userID, role: role}
}
func splitToken(token string) []string {
var result []string
var current []byte
for _, c := range token {
if c == '.' {
result = append(result, string(current))
current = nil
} else {
current = append(current, byte(c))
}
}
if len(current) > 0 {
result = append(result, string(current))
}
return result
}
func base64URLDecode(s string) ([]byte, error) {
return base64.RawURLEncoding.DecodeString(s)
}
func extractPaymentIDFromPath(path string) (string, string) {
patterns := []struct {
prefix string
paramName string
}{
{"/api/admin/payments/", "payment_id"},
{"/api/admin/bookings/", "id"},
{"/api/bookings/", "id"},
{"/api/user/payment-methods/", "id"},
}
for _, p := range patterns {
if idx := findPaymentLastSegment(path, p.prefix); idx >= 0 {
endIdx := len(path)
for i := idx; i < len(path); i++ {
if path[i] == '/' {
endIdx = i
break
}
}
return path[idx:endIdx], p.paramName
}
}
return "", ""
}
func findPaymentLastSegment(path, prefix string) int {
for i := len(path) - 1; i >= len(prefix); i-- {
if len(path) > i && path[i-len(prefix):i] == prefix {
return i
}
}
return -1
}
func parsePaymentResponseBody(w *httptest.ResponseRecorder, dest interface{}) error {
return json.Unmarshal(w.Body.Bytes(), dest)
}
func TestTerminalPayment_HappyPath(t *testing.T) {
resetTestData(t)
_, bookingID, _ := setupTestData(t)
adminToken := jwt.GenerateAdminToken()
req := CreateTerminalPaymentRequest{
Amount: 5000,
PaymentType: "full",
TipEnabled: true,
}
handler := CreateTerminalPayment
w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp CheckoutResponse
if err := parsePaymentResponseBody(w, &resp); err != nil {
t.Errorf("failed to parse response: %v", err)
}
if resp.CheckoutID == "" {
t.Error("expected checkout ID to be set")
}
if resp.Status != "PENDING" {
t.Errorf("expected status PENDING, got %s", resp.Status)
}
var count int
err := db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&count)
if err != nil {
t.Errorf("failed to query payments: %v", err)
}
if count != 0 {
t.Errorf("expected 0 payments (created on completion), got %d", count)
}
}
func setupTestData(t *testing.T) (string, string, string) {
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)
}
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
_, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'in_progress' WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to update booking status: %v", err)
}
return userID, bookingID, serviceID
}
func TestTerminalPayment_PriceOverride(t *testing.T) {
resetTestData(t)
_, bookingID, _ := setupTestData(t)
adminToken := jwt.GenerateAdminToken()
overrideAmount := int64(3000)
req := CreateTerminalPaymentRequest{
Amount: 5000,
PaymentType: "full",
OverrideAmount: &overrideAmount,
TipEnabled: false,
}
handler := CreateTerminalPayment
w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp CheckoutResponse
if err := parsePaymentResponseBody(w, &resp); err != nil {
t.Errorf("failed to parse response: %v", err)
}
}
func TestTerminalPayment_BookingNotInProgress(t *testing.T) {
resetTestData(t)
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)
}
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
adminToken := jwt.GenerateAdminToken()
req := CreateTerminalPaymentRequest{
Amount: 5000,
PaymentType: "full",
}
handler := CreateTerminalPayment
w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
_ = serviceID
}
func TestTerminalPayment_BookingNotFound(t *testing.T) {
resetTestData(t)
adminToken := jwt.GenerateAdminToken()
req := CreateTerminalPaymentRequest{
Amount: 5000,
PaymentType: "full",
}
handler := CreateTerminalPayment
w := makePaymentRequest(handler, "POST", "/api/admin/bookings/non-existent/payment", req, adminToken)
if w.Code != http.StatusNotFound {
t.Errorf("expected status 404, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestOnlinePayment_NewCard_Deposit(t *testing.T) {
resetTestData(t)
userID, bookingID, _ := setupTestData(t)
userToken := jwt.GenerateUserToken(userID)
cardToken := "cnon:test-card-nonce"
req := CreateBookingPaymentRequest{
Amount: 2500,
PaymentType: "deposit",
NewCardToken: &cardToken,
SaveCard: true,
IdempotencyKey: "deposit-key-1",
}
handler := CreateBookingPayment
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp PaymentResponse
if err := parsePaymentResponseBody(w, &resp); err != nil {
t.Errorf("failed to parse response: %v", err)
}
if resp.ID == "" {
t.Error("expected payment ID to be set")
}
if resp.Status != "completed" {
t.Errorf("expected status completed, got %s", resp.Status)
}
if resp.Amount != 2500 {
t.Errorf("expected amount 2500, got %d", resp.Amount)
}
var count int
err := db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&count)
if err != nil {
t.Errorf("failed to query payments: %v", err)
}
if count != 1 {
t.Errorf("expected 1 payment, got %d", count)
}
}
func TestOnlinePayment_SavedCard(t *testing.T) {
resetTestData(t)
userID, bookingID, _ := setupTestData(t)
cardID, err := fixtures.CreateTestPaymentMethod(db.DB, userID, "cfa_mock_card_123", "VISA", "4242")
if err != nil {
t.Fatalf("failed to create payment method: %v", err)
}
userToken := jwt.GenerateUserToken(userID)
req := CreateBookingPaymentRequest{
Amount: 5000,
PaymentType: "full",
CardID: &cardID,
IdempotencyKey: "saved-card-key-1",
}
handler := CreateBookingPayment
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp PaymentResponse
if err := parsePaymentResponseBody(w, &resp); err != nil {
t.Errorf("failed to parse response: %v", err)
}
if resp.Status != "completed" {
t.Errorf("expected status completed, got %s", resp.Status)
}
}
func TestOnlinePayment_BookingNotOwned(t *testing.T) {
resetTestData(t)
_, bookingID, _ := setupTestData(t)
otherUserID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create other user: %v", err)
}
userToken := jwt.GenerateUserToken(otherUserID)
cardToken := "cnon:test-card-nonce"
req := CreateBookingPaymentRequest{
Amount: 5000,
PaymentType: "full",
NewCardToken: &cardToken,
IdempotencyKey: "not-owned-key-1",
}
handler := CreateBookingPayment
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken)
if w.Code != http.StatusForbidden {
t.Errorf("expected status 403, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestGetUserPaymentMethods_HasCards(t *testing.T) {
resetTestData(t)
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
_, err = fixtures.CreateTestPaymentMethod(db.DB, userID, "cfa_card_1", "VISA", "1111")
if err != nil {
t.Fatalf("failed to create payment method 1: %v", err)
}
_, err = fixtures.CreateTestPaymentMethod(db.DB, userID, "cfa_card_2", "MASTERCARD", "2222")
if err != nil {
t.Fatalf("failed to create payment method 2: %v", err)
}
userToken := jwt.GenerateUserToken(userID)
handler := GetUserPaymentMethods
w := makePaymentRequest(handler, "GET", "/api/user/payment-methods", nil, userToken)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var cards []SavedCard
if err := parsePaymentResponseBody(w, &cards); err != nil {
t.Errorf("failed to parse response: %v", err)
}
if len(cards) != 2 {
t.Errorf("expected 2 cards, got %d", len(cards))
}
}
func TestDeletePaymentMethod(t *testing.T) {
resetTestData(t)
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
cardID, err := fixtures.CreateTestPaymentMethod(db.DB, userID, "cfa_card_delete", "VISA", "9999")
if err != nil {
t.Fatalf("failed to create payment method: %v", err)
}
userToken := jwt.GenerateUserToken(userID)
handler := DeletePaymentMethod
w := makePaymentRequest(handler, "DELETE", "/api/user/payment-methods/"+cardID, nil, userToken)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp map[string]string
if err := parsePaymentResponseBody(w, &resp); err != nil {
t.Errorf("failed to parse response: %v", err)
}
if resp["status"] != "deleted" {
t.Errorf("expected status deleted, got %s", resp["status"])
}
}
func TestRefund_FullRefund(t *testing.T) {
resetTestData(t)
_, bookingID, _ := setupTestData(t)
adminID, err := fixtures.CreateTestAdminUser(db.DB)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
paymentID, err := fixtures.CreateTestPayment(db.DB, bookingID, 5000.00, "in_person_card", "full", "completed")
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
squarePaymentID := "sqp_test_123"
_, err = db.DB.Exec(context.Background(), "UPDATE payments SET square_payment_id = $1 WHERE id = $2", squarePaymentID, paymentID)
if err != nil {
t.Fatalf("failed to update payment: %v", err)
}
req := RefundRequest{
Amount: 5000,
Reason: "customer request",
}
handler := RefundPayment
w := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp RefundResponse
if err := parsePaymentResponseBody(w, &resp); err != nil {
t.Errorf("failed to parse response: %v", err)
}
if resp.Amount != 5000 {
t.Errorf("expected amount 5000, got %d", resp.Amount)
}
if resp.Status != "completed" {
t.Errorf("expected status completed, got %s", resp.Status)
}
}
func TestRefund_PartialRefund(t *testing.T) {
resetTestData(t)
_, bookingID, _ := setupTestData(t)
adminID, err := fixtures.CreateTestAdminUser(db.DB)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
paymentID, err := fixtures.CreateTestPayment(db.DB, bookingID, 5000.00, "in_person_card", "full", "completed")
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
squarePaymentID := "sqp_test_456"
_, err = db.DB.Exec(context.Background(), "UPDATE payments SET square_payment_id = $1 WHERE id = $2", squarePaymentID, paymentID)
if err != nil {
t.Fatalf("failed to update payment: %v", err)
}
req := RefundRequest{
Amount: 2500,
Reason: "partial refund",
}
handler := RefundPayment
w := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp RefundResponse
if err := parsePaymentResponseBody(w, &resp); err != nil {
t.Errorf("failed to parse response: %v", err)
}
if resp.Amount != 2500 {
t.Errorf("expected amount 2500, got %d", resp.Amount)
}
}
func TestRefund_OverRefundRejected(t *testing.T) {
resetTestData(t)
_, bookingID, _ := setupTestData(t)
adminToken := jwt.GenerateAdminToken()
paymentID, err := fixtures.CreateTestPayment(db.DB, bookingID, 50.00, "in_person_card", "full", "completed")
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
squarePaymentID := "sqp_test_789"
_, err = db.DB.Exec(context.Background(), "UPDATE payments SET square_payment_id = $1 WHERE id = $2", squarePaymentID, paymentID)
if err != nil {
t.Fatalf("failed to update payment: %v", err)
}
req := RefundRequest{
Amount: 6000,
Reason: "over refund attempt",
}
handler := RefundPayment
w := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestRefund_PaymentNotFound(t *testing.T) {
resetTestData(t)
adminToken := jwt.GenerateAdminToken()
req := RefundRequest{
Amount: 1000,
Reason: "test",
}
handler := RefundPayment
w := makePaymentRequest(handler, "POST", "/api/admin/payments/non-existent/refund", req, adminToken)
if w.Code != http.StatusNotFound {
t.Errorf("expected status 404, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestRefund_PendingPaymentRejected(t *testing.T) {
resetTestData(t)
_, bookingID, _ := setupTestData(t)
adminToken := jwt.GenerateAdminToken()
paymentID, err := fixtures.CreateTestPayment(db.DB, bookingID, 5000.00, "in_person_card", "full", "pending")
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
req := RefundRequest{
Amount: 5000,
Reason: "test",
}
handler := RefundPayment
w := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestTipPayment_HappyPath(t *testing.T) {
resetTestData(t)
userID, bookingID, _ := setupTestData(t)
_, err := fixtures.CreateTestPayment(db.DB, bookingID, 5000.00, "online_square", "full", "completed")
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
userToken := jwt.GenerateUserToken(userID)
cardToken := "cnon:tip-card"
req := CreateTipPaymentRequest{
Amount: 500,
CardToken: cardToken,
}
handler := CreateTipPayment
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp PaymentResponse
if err := parsePaymentResponseBody(w, &resp); err != nil {
t.Errorf("failed to parse response: %v", err)
}
if resp.PaymentType != "tip" {
t.Errorf("expected payment type tip, got %s", resp.PaymentType)
}
if resp.Amount != 500 {
t.Errorf("expected amount 500, got %d", resp.Amount)
}
}
func TestTipPayment_NoPriorPayment(t *testing.T) {
resetTestData(t)
userID, bookingID, _ := setupTestData(t)
userToken := jwt.GenerateUserToken(userID)
cardToken := "cnon:tip-card"
req := CreateTipPaymentRequest{
Amount: 500,
CardToken: cardToken,
}
handler := CreateTipPayment
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestIdempotency_SameKeyReturnsExisting(t *testing.T) {
resetTestData(t)
userID, bookingID, _ := setupTestData(t)
userToken := jwt.GenerateUserToken(userID)
cardToken := "cnon:idempotent-card"
idempotencyKey := "idempotent-same-key"
req1 := CreateBookingPaymentRequest{
Amount: 5000,
PaymentType: "full",
NewCardToken: &cardToken,
IdempotencyKey: idempotencyKey,
}
handler := CreateBookingPayment
w1 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req1, userToken)
if w1.Code != http.StatusOK {
t.Errorf("first request expected status 200, got %d. body: %s", w1.Code, w1.Body.String())
}
var resp1 PaymentResponse
if err := parsePaymentResponseBody(w1, &resp1); err != nil {
t.Errorf("failed to parse first response: %v", err)
}
req2 := CreateBookingPaymentRequest{
Amount: 5000,
PaymentType: "full",
NewCardToken: &cardToken,
IdempotencyKey: idempotencyKey,
}
w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req2, userToken)
if w2.Code != http.StatusOK {
t.Errorf("second request expected status 200, got %d. body: %s", w2.Code, w2.Body.String())
}
var resp2 PaymentResponse
if err := parsePaymentResponseBody(w2, &resp2); err != nil {
t.Errorf("failed to parse second response: %v", err)
}
if resp1.ID != resp2.ID {
t.Errorf("expected same payment ID, got %s and %s", resp1.ID, resp2.ID)
}
var count int
err := db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&count)
if err != nil {
t.Errorf("failed to query payments: %v", err)
}
if count != 1 {
t.Errorf("expected 1 payment (idempotent), got %d", count)
}
}
func TestIdempotency_DifferentKeyCreatesNew(t *testing.T) {
resetTestData(t)
userID, bookingID, _ := setupTestData(t)
userToken := jwt.GenerateUserToken(userID)
cardToken := "cnon:different-key-card"
req1 := CreateBookingPaymentRequest{
Amount: 5000,
PaymentType: "full",
NewCardToken: &cardToken,
IdempotencyKey: "key-1",
}
handler := CreateBookingPayment
w1 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req1, userToken)
if w1.Code != http.StatusOK {
t.Errorf("first request expected status 200, got %d. body: %s", w1.Code, w1.Body.String())
}
req2 := CreateBookingPaymentRequest{
Amount: 5000,
PaymentType: "full",
NewCardToken: &cardToken,
IdempotencyKey: "key-2",
}
w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req2, userToken)
if w2.Code != http.StatusOK {
t.Errorf("second request expected status 200, got %d. body: %s", w2.Code, w2.Body.String())
}
var count int
err := db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&count)
if err != nil {
t.Errorf("failed to query payments: %v", err)
}
if count != 2 {
t.Errorf("expected 2 payments (different keys), got %d", count)
}
}
func TestSquareWebhook_DevMode_NoSignature(t *testing.T) {
resetTestData(t)
req := httptest.NewRequest("POST", "/api/webhooks/square", nil)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
_ = req
_ = w
t.Skip("webhook handler tested in webhooks package")
}
// ============================================================
// User Booking Payment Tests — deposit, full, partial, balance
// ============================================================
func setupDepositBooking(t *testing.T) (string, string) {
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)
}
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
_, err = db.DB.Exec(context.Background(),
"UPDATE bookings SET status = 'confirmed', deposit_required = TRUE WHERE id = $1",
bookingID)
if err != nil {
t.Fatalf("failed to update booking: %v", err)
}
return userID, bookingID
}
func TestBookingPayment_Deposit_HappyPath(t *testing.T) {
resetTestData(t)
userID, bookingID := setupDepositBooking(t)
userToken := jwt.GenerateUserToken(userID)
cardToken := "cnon:deposit-card"
req := CreateBookingPaymentRequest{
Amount: 2500,
PaymentType: "deposit",
NewCardToken: &cardToken,
SaveCard: false,
IdempotencyKey: "deposit-test-1",
}
handler := CreateBookingPayment
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp PaymentResponse
if err := parsePaymentResponseBody(w, &resp); err != nil {
t.Errorf("failed to parse response: %v", err)
}
if resp.PaymentType != "deposit" {
t.Errorf("expected payment type deposit, got %s", resp.PaymentType)
}
if resp.Amount != 2500 {
t.Errorf("expected amount 2500, got %d", resp.Amount)
}
if resp.Status != "completed" {
t.Errorf("expected status completed, got %s", resp.Status)
}
var count int
err := db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_type = 'deposit'", bookingID).Scan(&count)
if err != nil {
t.Errorf("failed to query payments: %v", err)
}
if count != 1 {
t.Errorf("expected 1 deposit payment, got %d", count)
}
}
func TestBookingPayment_FullPayment(t *testing.T) {
resetTestData(t)
userID, bookingID := setupDepositBooking(t)
userToken := jwt.GenerateUserToken(userID)
cardToken := "cnon:full-card"
req := CreateBookingPaymentRequest{
Amount: 5000,
PaymentType: "full",
NewCardToken: &cardToken,
IdempotencyKey: "full-test-1",
}
handler := CreateBookingPayment
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp PaymentResponse
if err := parsePaymentResponseBody(w, &resp); err != nil {
t.Errorf("failed to parse response: %v", err)
}
if resp.PaymentType != "full" {
t.Errorf("expected payment type full, got %s", resp.PaymentType)
}
if resp.Amount != 5000 {
t.Errorf("expected amount 5000, got %d", resp.Amount)
}
}
func TestBookingPayment_PartialPayment(t *testing.T) {
resetTestData(t)
userID, bookingID := setupDepositBooking(t)
userToken := jwt.GenerateUserToken(userID)
cardToken := "cnon:partial-card"
req := CreateBookingPaymentRequest{
Amount: 1500,
PaymentType: "partial",
NewCardToken: &cardToken,
IdempotencyKey: "partial-test-1",
}
handler := CreateBookingPayment
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp PaymentResponse
if err := parsePaymentResponseBody(w, &resp); err != nil {
t.Errorf("failed to parse response: %v", err)
}
if resp.PaymentType != "partial" {
t.Errorf("expected payment type partial, got %s", resp.PaymentType)
}
if resp.Amount != 1500 {
t.Errorf("expected amount 1500, got %d", resp.Amount)
}
}
func TestBookingPayment_BalancePayment(t *testing.T) {
resetTestData(t)
userID, bookingID := setupDepositBooking(t)
userToken := jwt.GenerateUserToken(userID)
cardToken := "cnon:balance-card"
req := CreateBookingPaymentRequest{
Amount: 3500,
PaymentType: "balance",
NewCardToken: &cardToken,
IdempotencyKey: "balance-test-1",
}
handler := CreateBookingPayment
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp PaymentResponse
if err := parsePaymentResponseBody(w, &resp); err != nil {
t.Errorf("failed to parse response: %v", err)
}
if resp.PaymentType != "balance" {
t.Errorf("expected payment type balance, got %s", resp.PaymentType)
}
}
func TestBookingPayment_ZeroAmountRejected(t *testing.T) {
resetTestData(t)
userID, bookingID := setupDepositBooking(t)
userToken := jwt.GenerateUserToken(userID)
cardToken := "cnon:zero-card"
req := CreateBookingPaymentRequest{
Amount: 0,
PaymentType: "full",
NewCardToken: &cardToken,
IdempotencyKey: "zero-test-1",
}
handler := CreateBookingPayment
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestBookingPayment_NegativeAmountRejected(t *testing.T) {
resetTestData(t)
userID, bookingID := setupDepositBooking(t)
userToken := jwt.GenerateUserToken(userID)
cardToken := "cnon:neg-card"
req := CreateBookingPaymentRequest{
Amount: -100,
PaymentType: "full",
NewCardToken: &cardToken,
IdempotencyKey: "neg-test-1",
}
handler := CreateBookingPayment
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestBookingPayment_InvalidPaymentTypeRejected(t *testing.T) {
resetTestData(t)
userID, bookingID := setupDepositBooking(t)
userToken := jwt.GenerateUserToken(userID)
cardToken := "cnon:invalid-type-card"
req := CreateBookingPaymentRequest{
Amount: 5000,
PaymentType: "invalid_type",
NewCardToken: &cardToken,
IdempotencyKey: "invalid-type-test-1",
}
handler := CreateBookingPayment
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestBookingPayment_NoAuthRejected(t *testing.T) {
resetTestData(t)
_, bookingID := setupDepositBooking(t)
cardToken := "cnon:no-auth-card"
req := CreateBookingPaymentRequest{
Amount: 5000,
PaymentType: "full",
NewCardToken: &cardToken,
IdempotencyKey: "no-auth-test-1",
}
handler := CreateBookingPayment
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, "")
if w.Code != http.StatusUnauthorized {
t.Errorf("expected status 401, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestBookingPayment_DepositFollowedByBalance(t *testing.T) {
resetTestData(t)
userID, bookingID := setupDepositBooking(t)
userToken := jwt.GenerateUserToken(userID)
cardToken := "cnon:deposit-balance-card"
req1 := CreateBookingPaymentRequest{
Amount: 2500,
PaymentType: "deposit",
NewCardToken: &cardToken,
IdempotencyKey: "deposit-balance-1",
}
handler := CreateBookingPayment
w1 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req1, userToken)
if w1.Code != http.StatusOK {
t.Errorf("deposit: expected status 200, got %d. body: %s", w1.Code, w1.Body.String())
}
req2 := CreateBookingPaymentRequest{
Amount: 2500,
PaymentType: "balance",
NewCardToken: &cardToken,
IdempotencyKey: "deposit-balance-2",
}
w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req2, userToken)
if w2.Code != http.StatusOK {
t.Errorf("balance: expected status 200, got %d. body: %s", w2.Code, w2.Body.String())
}
var count int
err := db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&count)
if err != nil {
t.Errorf("failed to query payments: %v", err)
}
if count != 2 {
t.Errorf("expected 2 payments, got %d", count)
}
}
func TestBookingPayment_PartialFollowedByBalance(t *testing.T) {
resetTestData(t)
userID, bookingID := setupDepositBooking(t)
userToken := jwt.GenerateUserToken(userID)
cardToken := "cnon:partial-balance-card"
req1 := CreateBookingPaymentRequest{
Amount: 1000,
PaymentType: "partial",
NewCardToken: &cardToken,
IdempotencyKey: "partial-balance-1",
}
handler := CreateBookingPayment
w1 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req1, userToken)
if w1.Code != http.StatusOK {
t.Errorf("partial: expected status 200, got %d. body: %s", w1.Code, w1.Body.String())
}
req2 := CreateBookingPaymentRequest{
Amount: 4000,
PaymentType: "balance",
NewCardToken: &cardToken,
IdempotencyKey: "partial-balance-2",
}
w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req2, userToken)
if w2.Code != http.StatusOK {
t.Errorf("balance: expected status 200, got %d. body: %s", w2.Code, w2.Body.String())
}
var count int
err := db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&count)
if err != nil {
t.Errorf("failed to query payments: %v", err)
}
if count != 2 {
t.Errorf("expected 2 payments, got %d", count)
}
}
func TestTipPayment_WrongOwnerRejected(t *testing.T) {
resetTestData(t)
_, bookingID, _ := setupTestData(t)
_, err := fixtures.CreateTestPayment(db.DB, bookingID, 5000.00, "online_square", "full", "completed")
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
otherUserID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create other user: %v", err)
}
otherToken := jwt.GenerateUserToken(otherUserID)
req := CreateTipPaymentRequest{
Amount: 500,
CardToken: "cnon:wrong-owner-tip",
}
handler := CreateTipPayment
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, otherToken)
if w.Code != http.StatusForbidden {
t.Errorf("expected status 403, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestTipPayment_MultipleTipsAllowed(t *testing.T) {
resetTestData(t)
userID, bookingID, _ := setupTestData(t)
_, err := fixtures.CreateTestPayment(db.DB, bookingID, 5000.00, "online_square", "full", "completed")
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
userToken := jwt.GenerateUserToken(userID)
for i := 0; i < 3; i++ {
req := CreateTipPaymentRequest{
Amount: int64(200 + i*100),
CardToken: fmt.Sprintf("cnon:multi-tip-%d", i),
}
handler := CreateTipPayment
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken)
if w.Code != http.StatusOK {
t.Errorf("tip %d: expected status 200, got %d. body: %s", i, w.Code, w.Body.String())
}
}
var count int
err = db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_type = 'tip'", bookingID).Scan(&count)
if err != nil {
t.Errorf("failed to query tip payments: %v", err)
}
if count != 3 {
t.Errorf("expected 3 tip payments, got %d", count)
}
}
func TestGetUserPaymentMethods_NoCards(t *testing.T) {
resetTestData(t)
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
userToken := jwt.GenerateUserToken(userID)
handler := GetUserPaymentMethods
w := makePaymentRequest(handler, "GET", "/api/user/payment-methods", nil, userToken)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var cards []SavedCard
if err := parsePaymentResponseBody(w, &cards); err != nil {
t.Errorf("failed to parse response: %v", err)
}
if len(cards) != 0 {
t.Errorf("expected 0 cards, got %d", len(cards))
}
}
func TestDeletePaymentMethod_WrongOwnerRejected(t *testing.T) {
resetTestData(t)
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
cardID, err := fixtures.CreateTestPaymentMethod(db.DB, userID, "cfa_wrong_owner", "VISA", "0000")
if err != nil {
t.Fatalf("failed to create payment method: %v", err)
}
otherUserID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create other user: %v", err)
}
otherToken := jwt.GenerateUserToken(otherUserID)
handler := DeletePaymentMethod
w := makePaymentRequest(handler, "DELETE", "/api/user/payment-methods/"+cardID, nil, otherToken)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var count int
err = db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM user_saved_cards WHERE id = $1 AND deleted_at IS NULL", cardID).Scan(&count)
if err != nil {
t.Errorf("failed to query card: %v", err)
}
if count != 1 {
t.Error("expected card to still exist (not deleted by wrong owner)")
}
}
func TestValidatePartialAmount(t *testing.T) {
tests := []struct {
name string
amountCents int64
remainingCents int64
expectErr bool
}{
{"valid partial", 500, 1000, false},
{"exact remaining", 1000, 1000, false},
{"exceeds remaining", 1500, 1000, true},
{"zero amount", 0, 1000, true},
{"negative amount", -100, 1000, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidatePartialAmount(tt.amountCents, tt.remainingCents)
if tt.expectErr && err == nil {
t.Error("expected error, got nil")
}
if !tt.expectErr && err != nil {
t.Errorf("expected no error, got %v", err)
}
})
}
}
func TestGetBookingRemainingBalanceCents(t *testing.T) {
resetTestData(t)
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 service: %v", err)
}
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
service := NewPaymentService()
initialRemaining, err := service.GetBookingRemainingBalanceCents(context.Background(), bookingID)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if initialRemaining <= 0 {
t.Fatalf("expected positive remaining balance, got %d", initialRemaining)
}
_, err = service.CreatePaymentRecord(context.Background(), PaymentRecord{
BookingID: bookingID,
PaymentType: "partial",
PaymentMethod: "cash",
Status: "completed",
Amount: 20.00,
})
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
afterPartial, err := service.GetBookingRemainingBalanceCents(context.Background(), bookingID)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if afterPartial != initialRemaining-2000 {
t.Errorf("expected %d cents remaining after £20 payment, got %d", initialRemaining-2000, afterPartial)
}
_, err = service.CreatePaymentRecord(context.Background(), PaymentRecord{
BookingID: bookingID,
PaymentType: "balance",
PaymentMethod: "cash",
Status: "completed",
Amount: float64(afterPartial) / 100.0,
})
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
afterFull, err := service.GetBookingRemainingBalanceCents(context.Background(), bookingID)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if afterFull != 0 {
t.Errorf("expected 0 cents remaining after full payment, got %d", afterFull)
}
}
func TestCreatePaymentMethod_HappyPath(t *testing.T) {
resetTestData(t)
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
token := jwt.GenerateUserToken(userID)
handler := CreatePaymentMethod
reqBody := CreatePaymentMethodRequest{
CardNumber: "4111111111111111",
Expiry: "12/30",
CVC: "123",
}
w := makePaymentRequest(handler, "POST", "/api/user/payment-methods", reqBody, token)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
return
}
var card SavedCard
if err := json.Unmarshal(w.Body.Bytes(), &card); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if card.Brand != "VISA" {
t.Errorf("expected brand VISA, got %s", card.Brand)
}
if card.Last4 != "1111" {
t.Errorf("expected last4 1111, got %s", card.Last4)
}
if !card.IsDefault {
t.Error("expected first card to be default")
}
}
func TestCreatePaymentMethod_ExpiredCardRejected(t *testing.T) {
resetTestData(t)
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
token := jwt.GenerateUserToken(userID)
handler := CreatePaymentMethod
reqBody := CreatePaymentMethodRequest{
CardNumber: "4111111111111111",
Expiry: "01/20",
CVC: "123",
}
w := makePaymentRequest(handler, "POST", "/api/user/payment-methods", reqBody, token)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestCreatePaymentMethod_InvalidExpiryRejected(t *testing.T) {
resetTestData(t)
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
token := jwt.GenerateUserToken(userID)
tests := []struct {
name string
expiry string
}{
{"bad format", "12-30"},
{"bad month", "13/30"},
{"bad year", "12/abc"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
handler := CreatePaymentMethod
reqBody := CreatePaymentMethodRequest{
CardNumber: "4111111111111111",
Expiry: tt.expiry,
CVC: "123",
}
w := makePaymentRequest(handler, "POST", "/api/user/payment-methods", reqBody, token)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
})
}
}
func TestCreatePaymentMethod_MissingFieldsRejected(t *testing.T) {
resetTestData(t)
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
token := jwt.GenerateUserToken(userID)
tests := []struct {
name string
body CreatePaymentMethodRequest
}{
{"no card number", CreatePaymentMethodRequest{Expiry: "12/30", CVC: "123"}},
{"no expiry", CreatePaymentMethodRequest{CardNumber: "4111111111111111", CVC: "123"}},
{"no cvc", CreatePaymentMethodRequest{CardNumber: "4111111111111111", Expiry: "12/30"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
handler := CreatePaymentMethod
w := makePaymentRequest(handler, "POST", "/api/user/payment-methods", tt.body, token)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
})
}
}
func TestCreatePaymentMethod_NoAuthRejected(t *testing.T) {
resetTestData(t)
handler := CreatePaymentMethod
w := makePaymentRequest(handler, "POST", "/api/user/payment-methods", CreatePaymentMethodRequest{
CardNumber: "4111111111111111",
Expiry: "12/30",
CVC: "123",
}, "")
if w.Code != http.StatusUnauthorized {
t.Errorf("expected status 401, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestCreatePaymentMethod_SecondCardNotDefault(t *testing.T) {
resetTestData(t)
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
token := jwt.GenerateUserToken(userID)
// Create first card
handler := CreatePaymentMethod
reqBody := CreatePaymentMethodRequest{
CardNumber: "4111111111111111",
Expiry: "12/30",
CVC: "123",
}
w := makePaymentRequest(handler, "POST", "/api/user/payment-methods", reqBody, token)
if w.Code != http.StatusOK {
t.Fatalf("failed to create first card: %d. body: %s", w.Code, w.Body.String())
}
reqBody2 := CreatePaymentMethodRequest{
CardNumber: "5500000000000004",
Expiry: "06/30",
CVC: "456",
}
w = makePaymentRequest(handler, "POST", "/api/user/payment-methods", reqBody2, token)
if w.Code != http.StatusOK {
t.Fatalf("failed to create second card: %d. body: %s", w.Code, w.Body.String())
}
var card SavedCard
if err := json.Unmarshal(w.Body.Bytes(), &card); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if card.Brand != "MASTERCARD" {
t.Errorf("expected brand MASTERCARD, got %s", card.Brand)
}
if card.IsDefault {
t.Error("expected second card to NOT be default")
}
}