N1 (HIGH) — BuyGiftCard concurrent same-key retry could double-issue gift cards (2× value for 1 charge). Added pg_advisory_lock on the idempotency key (mirroring the tip pattern) acquired before the idempotency check, so concurrent same-key retries serialize and only one executes gift-card creation. N2 — Amount-equality guards in both reuse branches (CreateTipPayment and BuyGiftCard). A same-key retry with a different amount now returns 400 instead of silently mutating the pending record's books/VAT/refund caps. N3 — test coverage: - TestBuyGiftCard_RetryPending_ReattemptsCharge: pending record + same-key retry re-attempts, reuses the record (count=1), completes, and issues the gift card exactly once. - TestCreateCheckoutHTTP_DeviceOptionsWireShape: httptest.Server asserts device_id is under checkout.device_options (not top-level). Extracted createCheckoutHTTPWithClient for injectable base URL. - MockClient.CreatePayment now dedups on idempotency key (paymentByKey map), matching real Square behaviour. N4 — Corrected the savepoint comments in handlers.go and giftcards.go: the savepoint only exists in the test harness; in production db.Conn.Begin is a plain tx and the status UPDATE runs on a separate pooled connection. Commit is a harmless no-op in prod but required in tests. Bonus bug fixed: CheckIdempotencyByKey scanned NULL booking_id/gift_card_id (gift-card purchases) into plain string, failing with 'cannot scan NULL'. Now uses sql.NullString. Docs: Technical Manual.md:53 and Feature Catalog.md (2.1, 2.5) corrected — no longer claim Web Payments SDK is live; new-card entry is documented as pending P11, saved-card flow works via ccof tokens, dev mock rejects raw PANs.
103 lines
3.1 KiB
Go
103 lines
3.1 KiB
Go
//go:build test
|
|
|
|
package square
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
)
|
|
|
|
func TestPaymentFromSquare_ElseBranch_SurfacesBrandWithoutCardID(t *testing.T) {
|
|
p := &sqPayment{
|
|
ID: "pay_1",
|
|
Status: "COMPLETED",
|
|
TotalMoney: sqMoney{Amount: 5000, Currency: "GBP"},
|
|
CardDetails: &sqCardDetails{
|
|
Card: sqCard{
|
|
ID: "",
|
|
CardBrand: "VISA",
|
|
Last4: "4242",
|
|
},
|
|
},
|
|
}
|
|
|
|
result := paymentFromSquare(p)
|
|
if result.CardBrand != "VISA" {
|
|
t.Errorf("expected CardBrand VISA, got %s", result.CardBrand)
|
|
}
|
|
if result.CardLast4 != "4242" {
|
|
t.Errorf("expected CardLast4 4242, got %s", result.CardLast4)
|
|
}
|
|
}
|
|
|
|
func TestPaymentFromSquare_NilCardDetails(t *testing.T) {
|
|
p := &sqPayment{
|
|
ID: "pay_2",
|
|
Status: "COMPLETED",
|
|
TotalMoney: sqMoney{Amount: 2500, Currency: "GBP"},
|
|
CardDetails: nil,
|
|
}
|
|
|
|
result := paymentFromSquare(p)
|
|
if result.CardBrand != "" {
|
|
t.Errorf("expected empty CardBrand when no card details, got %s", result.CardBrand)
|
|
}
|
|
if result.Amount != 2500 {
|
|
t.Errorf("expected amount 2500, got %d", result.Amount)
|
|
}
|
|
}
|
|
|
|
// TestCreateCheckoutHTTP_DeviceOptionsWireShape verifies the terminal checkout
|
|
// request puts device_id inside checkout.device_options (Square's required
|
|
// shape), not at the top level. A top-level device_id is rejected with 400 by
|
|
// Square's real API.
|
|
func TestCreateCheckoutHTTP_DeviceOptionsWireShape(t *testing.T) {
|
|
var captured map[string]any
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
t.Errorf("expected POST, got %s", r.Method)
|
|
}
|
|
if r.URL.Path != "/v2/terminals/checkouts" {
|
|
t.Errorf("expected /v2/terminals/checkouts, got %s", r.URL.Path)
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&captured); err != nil {
|
|
t.Errorf("failed to decode request body: %v", err)
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"checkout":{"id":"chk_test","status":"PENDING","amount_money":{"amount":5000,"currency":"GBP"},"created_at":"2026-07-31T00:00:00Z","updated_at":"2026-07-31T00:00:00Z"}}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
hc := &httpClient{baseURL: srv.URL, token: "test-token", http: srv.Client()}
|
|
|
|
_, err := createCheckoutHTTPWithClient(context.Background(), CreateCheckoutReq{
|
|
Amount: 5000,
|
|
Currency: "GBP",
|
|
IdempotencyKey: "ik-1",
|
|
DeviceID: "dvc_test",
|
|
}, hc)
|
|
if err != nil {
|
|
t.Fatalf("createCheckoutHTTP failed: %v", err)
|
|
}
|
|
|
|
checkout, ok := captured["checkout"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("expected checkout object in body, got %v", captured)
|
|
}
|
|
// device_id must NOT be at the top level
|
|
if _, hasTopLevel := captured["device_id"]; hasTopLevel {
|
|
t.Errorf("device_id must not be top-level in terminal checkout request: %v", captured)
|
|
}
|
|
// device_id must live under checkout.device_options
|
|
devOpts, ok := checkout["device_options"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("expected checkout.device_options in body, got %v", checkout)
|
|
}
|
|
if devOpts["device_id"] != "dvc_test" {
|
|
t.Errorf("expected checkout.device_options.device_id = dvc_test, got %v", devOpts)
|
|
}
|
|
}
|