//go:build test && dev package payments import ( "context" "errors" "net/http" "reflect" "testing" "crussell/internal/square" "crussell/testutils" ) // structuredSquareAPIError returns an error of the SAME concrete type the real // Square client produces for structured API errors (the unexported // *square.squareAPIError, re-stamped with the given HTTP status). The type is // not nameable outside internal/square and there is no exported constructor, // so the helper clones the dev mock's real structured 400 error (the only // package-visible producer) via reflection and rewrites its status code. This // mirrors the existing test's "build through the mock" style while covering // status codes the mock cannot produce (429/408/425/422/500). func structuredSquareAPIError(t *testing.T, status int) error { t.Helper() mc := square.NewDevClient().(*square.MockClient) _, err := mc.CreatePayment(context.Background(), square.CreatePaymentReq{ Amount: 1000, Currency: "GBP", SourceID: "ccof:card_1", }) if err == nil { t.Fatal("expected the mock to reject a ccof charge without a customer") } if square.ErrorStatusCode(err) == 0 { t.Fatal("expected the mock's ccof rejection to carry a structured status code") } v := reflect.ValueOf(err) if v.Kind() != reflect.Ptr { t.Fatalf("expected the structured error to be a pointer, got %v", v.Kind()) } clone := reflect.New(v.Elem().Type()) clone.Elem().Set(v.Elem()) clone.Elem().FieldByName("StatusCode").SetInt(int64(status)) return clone.Interface().(error) } // TestChargeFailureStatus classifies Square CreatePayment errors into // 402 (definitive decline) vs 503 (ambiguous) so the charge handlers surface // retryable failures as 503 (the pending record is resumed on a same-key // retry) and only definitively-rejected charges as 402. func TestChargeFailureStatus(t *testing.T) { ctx := context.Background() mc := square.NewDevClient().(*square.MockClient) // The dev mock produces a structured 4xx squareAPIError for a // card-on-file charge missing its required customer (mirrors a real // Square 400 INVALID_REQUEST_ERROR) — exercises the definitive-decline // classification through the real error type. _, structuredErr := mc.CreatePayment(ctx, square.CreatePaymentReq{ Amount: 1000, Currency: "GBP", SourceID: "ccof:card_1", }) if structuredErr == nil { t.Fatal("expected the mock to reject a ccof charge without a customer") } if square.ErrorStatusCode(structuredErr) == 0 { t.Fatal("expected the mock's ccof rejection to carry a structured status code") } tests := []struct { name string err error want int }{ {"structured 4xx decline → 402", structuredErr, http.StatusPaymentRequired}, {"plain mock failure (ambiguous) → 503", errors.New("mock: payment declined (simulated failure)"), http.StatusServiceUnavailable}, {"context deadline → 503", context.DeadlineExceeded, http.StatusServiceUnavailable}, {"context cancelled → 503", context.Canceled, http.StatusServiceUnavailable}, {"nil (defensive) → 402", nil, http.StatusPaymentRequired}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := chargeFailureStatus(tt.err); got != tt.want { t.Errorf("chargeFailureStatus(%v) = %d, want %d", tt.err, got, tt.want) } }) } } // TestChargeFailureStatus_RetryableCarveOuts locks the 429/408/425 carve-outs: // those retryable/ambiguous 4xx statuses must classify as 503 (ambiguous — // retry later), never as the 402 (definitive decline) that the generic 4xx // branch would produce. True declines (400/422) and 5xx keep their existing // classifications. func TestChargeFailureStatus_RetryableCarveOuts(t *testing.T) { tests := []struct { name string err error want int }{ {"structured 429 rate limited (retryable) → 503", structuredSquareAPIError(t, http.StatusTooManyRequests), http.StatusServiceUnavailable}, {"structured 408 request timeout (ambiguous) → 503", structuredSquareAPIError(t, http.StatusRequestTimeout), http.StatusServiceUnavailable}, {"structured 425 too early (ambiguous) → 503", structuredSquareAPIError(t, http.StatusTooEarly), http.StatusServiceUnavailable}, {"structured 422 unprocessable (definitive) → 402", structuredSquareAPIError(t, http.StatusUnprocessableEntity), http.StatusPaymentRequired}, {"structured 400 bad request (definitive) → 402", structuredSquareAPIError(t, http.StatusBadRequest), http.StatusPaymentRequired}, {"structured 500 server error (ambiguous) → 503", structuredSquareAPIError(t, http.StatusInternalServerError), http.StatusServiceUnavailable}, {"plain error (ambiguous) → 503", errors.New("mock: payment declined (simulated failure)"), http.StatusServiceUnavailable}, {"context deadline (ambiguous) → 503", context.DeadlineExceeded, http.StatusServiceUnavailable}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := chargeFailureStatus(tt.err); got != tt.want { t.Errorf("chargeFailureStatus(%v) = %d, want %d", tt.err, got, tt.want) } }) } } // TestChargeFailureStatus_DefaultAndEdgeStatuses pins the full status-space // classification, including the ambiguous DEFAULT branch (1xx/2xx/3xx): the // default MUST be 503 (ambiguous → retryable) — never 402, which labels a // definitive decline and suppresses the same-key retry that resumes the pending // record. 409 (Square IDEMPOTENCY_KEY_REUSED — a key reused with a different // request body) is a definitive client error and must stay 402, not fall into // the ambiguous bucket. func TestChargeFailureStatus_DefaultAndEdgeStatuses(t *testing.T) { tests := []struct { name string status int want int }{ {"0 (plain/transport error) → 503", 0, http.StatusServiceUnavailable}, {"1xx → 503 (ambiguous default)", http.StatusContinue, http.StatusServiceUnavailable}, {"3xx → 503 (ambiguous default)", http.StatusMultipleChoices, http.StatusServiceUnavailable}, {"400 → 402 (definitive)", http.StatusBadRequest, http.StatusPaymentRequired}, {"401 → 402 (definitive)", http.StatusUnauthorized, http.StatusPaymentRequired}, {"403 → 402 (definitive)", http.StatusForbidden, http.StatusPaymentRequired}, {"408 → 503 (retryable)", http.StatusRequestTimeout, http.StatusServiceUnavailable}, {"409 → 402 (idempotency-key conflict, definitive)", http.StatusConflict, http.StatusPaymentRequired}, {"425 → 503 (retryable)", http.StatusTooEarly, http.StatusServiceUnavailable}, {"429 → 503 (retryable)", http.StatusTooManyRequests, http.StatusServiceUnavailable}, {"500 → 503", http.StatusInternalServerError, http.StatusServiceUnavailable}, {"503 → 503", http.StatusServiceUnavailable, http.StatusServiceUnavailable}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { var err error if tt.status == 0 { err = errors.New("mock: payment declined (simulated failure)") } else { err = structuredSquareAPIError(t, tt.status) } if got := chargeFailureStatus(err); got != tt.want { t.Errorf("chargeFailureStatus(status=%d) = %d, want %d", tt.status, got, tt.want) } }) } } // TestCreateBookingPayment_AmbiguousSquareFailure_Returns503 verifies the // charge-failure classification end to end: the dev mock's simulated failure // is a PLAIN error (no structured Square status), so the handler now returns // 503 (ambiguous — the pending record stays pending for a same-key retry) // instead of 402 (which implied a definitive decline). func TestCreateBookingPayment_AmbiguousSquareFailure_Returns503(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) _, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed") origClient := SquareClient mc := square.NewDevClient().(*square.MockClient) mc.ShouldFail = true SquareClient = mc defer func() { SquareClient = origClient }() cardToken := "cnon:test-card-nonce" req := CreateBookingPaymentRequest{ Amount: 2500, PaymentType: "deposit", NewCardToken: &cardToken, IdempotencyKey: "ambiguous-503-" + bookingID, } handler := CreateBookingPayment w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) if w.Code != http.StatusServiceUnavailable { t.Fatalf("expected 503 for ambiguous mock Square failure, got %d: %s", w.Code, w.Body.String()) } // The pending record must be left pending (not failed) so a same-key retry // reuses it instead of creating a second Square charge. var status string err := tx.QueryRow(ctx, `SELECT status FROM payments WHERE booking_id = $1 AND idempotency_key = $2`, bookingID, req.IdempotencyKey).Scan(&status) if err != nil { t.Fatalf("failed to query payment status: %v", err) } if status != "pending" { t.Errorf("expected payment status 'pending' after ambiguous failure, got %q", status) } }