mirror of
https://github.com/marcusDenslow/terminalShop.git
synced 2026-10-25 10:02:21 +01:00
260 lines
8.8 KiB
Go
260 lines
8.8 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"testing"
|
|
|
|
"github.com/stripe/stripe-go/v78"
|
|
|
|
"terminalShop/pkg/database"
|
|
"terminalShop/pkg/models"
|
|
)
|
|
|
|
// buildPaymentFailedEvent constructs a stripe.Event whose Data.Raw decodes to
|
|
// a PaymentIntent referencing the given order via metadata. errCode is set on
|
|
// last_payment_error to drive the authentication_required short-circuit
|
|
// branch in handlePaymentIntentFailed (pass "" to omit the error block).
|
|
func buildPaymentFailedEvent(t *testing.T, piID string, orderID uint, errCode string) stripe.Event {
|
|
t.Helper()
|
|
pi := map[string]any{
|
|
"id": piID,
|
|
"metadata": map[string]string{"order_id": fmt.Sprintf("%d", orderID)},
|
|
}
|
|
if errCode != "" {
|
|
pi["last_payment_error"] = map[string]any{"code": errCode}
|
|
}
|
|
raw, err := json.Marshal(pi)
|
|
if err != nil {
|
|
t.Fatalf("marshal pi: %v", err)
|
|
}
|
|
return stripe.Event{
|
|
Type: "payment_intent.payment_failed",
|
|
Data: &stripe.EventData{Raw: raw},
|
|
}
|
|
}
|
|
|
|
func seedOrderForWebhook(t *testing.T, userID uint, status models.OrderStatus, piID string) models.Order {
|
|
t.Helper()
|
|
db := database.GetDB()
|
|
card := models.Card{
|
|
UserID: userID,
|
|
StripePaymentID: fmt.Sprintf("pm_test_%s", piID),
|
|
Last4: "4242", Brand: "Visa", ExpMonth: 12, ExpYear: 2030,
|
|
}
|
|
if err := db.Create(&card).Error; err != nil {
|
|
t.Fatalf("seed card: %v", err)
|
|
}
|
|
order := models.Order{
|
|
UserID: userID, CardID: card.ID,
|
|
StripePaymentID: piID,
|
|
Status: status,
|
|
Subtotal: 500, Total: 500,
|
|
ShippingName: "Test", ShippingStreet: "1 Main St",
|
|
ShippingCity: "PDX", ShippingState: "OR",
|
|
ShippingZip: "97201", ShippingCountry: "US",
|
|
}
|
|
if err := db.Create(&order).Error; err != nil {
|
|
t.Fatalf("seed order: %v", err)
|
|
}
|
|
return order
|
|
}
|
|
|
|
// TestHandlePaymentIntentFailed_RequiresActionFlipsToFailed verifies the
|
|
// widened predicate (IN (pending, requires_action)) catches a row in the new
|
|
// requires_action state. Without this case, a card_declined webhook arriving
|
|
// after respondRequiresAction wrote requires_action would leave the row
|
|
// stuck forever.
|
|
func TestHandlePaymentIntentFailed_RequiresActionFlipsToFailed(t *testing.T) {
|
|
testDB, user := setupCartTestDB(t)
|
|
defer func() { _ = os.Remove(testDB) }()
|
|
defer database.ResetForTesting()
|
|
|
|
order := seedOrderForWebhook(t, user.ID, models.OrderStatusRequiresAction, "pi_test_req_action_failed")
|
|
|
|
h := NewWebhookHandler("", "")
|
|
evt := buildPaymentFailedEvent(t, order.StripePaymentID, order.ID, "card_declined")
|
|
h.handlePaymentIntentFailed(context.Background(), evt)
|
|
|
|
db := database.GetDB()
|
|
var reloaded models.Order
|
|
if err := db.First(&reloaded, order.ID).Error; err != nil {
|
|
t.Fatalf("reload order: %v", err)
|
|
}
|
|
if reloaded.Status != models.OrderStatusFailed {
|
|
t.Fatalf("status: want %q, got %q", models.OrderStatusFailed, reloaded.Status)
|
|
}
|
|
}
|
|
|
|
// TestHandlePaymentIntentFailed_RequiresActionAuthRequiredShortCircuits
|
|
// verifies the existing short-circuit (note #3 in sca-psd2-compliance.md)
|
|
// still fires when the row is in requires_action and the error code is
|
|
// authentication_required. Without the short-circuit, the recovery flow
|
|
// would race the webhook and surface a momentarily-failed state to the TUI.
|
|
func TestHandlePaymentIntentFailed_RequiresActionAuthRequiredShortCircuits(t *testing.T) {
|
|
testDB, user := setupCartTestDB(t)
|
|
defer func() { _ = os.Remove(testDB) }()
|
|
defer database.ResetForTesting()
|
|
|
|
order := seedOrderForWebhook(t, user.ID, models.OrderStatusRequiresAction, "pi_test_req_action_authreq")
|
|
|
|
h := NewWebhookHandler("", "")
|
|
evt := buildPaymentFailedEvent(t, order.StripePaymentID, order.ID, "authentication_required")
|
|
h.handlePaymentIntentFailed(context.Background(), evt)
|
|
|
|
db := database.GetDB()
|
|
var reloaded models.Order
|
|
if err := db.First(&reloaded, order.ID).Error; err != nil {
|
|
t.Fatalf("reload order: %v", err)
|
|
}
|
|
if reloaded.Status != models.OrderStatusRequiresAction {
|
|
t.Fatalf("auth_required must not flip requires_action; got %q", reloaded.Status)
|
|
}
|
|
}
|
|
|
|
// TestHandlePaymentIntentFailed_PendingStillFlips guards the pre-existing
|
|
// pending -> failed transition against a regression where the widened
|
|
// predicate accidentally dropped pending from the source set.
|
|
func TestHandlePaymentIntentFailed_PendingStillFlips(t *testing.T) {
|
|
testDB, user := setupCartTestDB(t)
|
|
defer func() { _ = os.Remove(testDB) }()
|
|
defer database.ResetForTesting()
|
|
|
|
order := seedOrderForWebhook(t, user.ID, models.OrderStatusPending, "pi_test_pending_failed")
|
|
|
|
h := NewWebhookHandler("", "")
|
|
evt := buildPaymentFailedEvent(t, order.StripePaymentID, order.ID, "card_declined")
|
|
h.handlePaymentIntentFailed(context.Background(), evt)
|
|
|
|
db := database.GetDB()
|
|
var reloaded models.Order
|
|
if err := db.First(&reloaded, order.ID).Error; err != nil {
|
|
t.Fatalf("reload order: %v", err)
|
|
}
|
|
if reloaded.Status != models.OrderStatusFailed {
|
|
t.Fatalf("status: want %q, got %q", models.OrderStatusFailed, reloaded.Status)
|
|
}
|
|
}
|
|
|
|
// TestHandlePaymentIntentFailed_PaidNotTouched locks the upper boundary of
|
|
// the predicate: a paid row must NEVER be flipped to failed by a stale
|
|
// payment_failed webhook. Without this guard, a future maintainer could
|
|
// widen the IN clause to include paid (e.g. for some refund-retry flow)
|
|
// and silently break idempotency.
|
|
func TestHandlePaymentIntentFailed_PaidNotTouched(t *testing.T) {
|
|
testDB, user := setupCartTestDB(t)
|
|
defer func() { _ = os.Remove(testDB) }()
|
|
defer database.ResetForTesting()
|
|
|
|
order := seedOrderForWebhook(t, user.ID, models.OrderStatusPaid, "pi_test_paid_untouched")
|
|
|
|
h := NewWebhookHandler("", "")
|
|
evt := buildPaymentFailedEvent(t, order.StripePaymentID, order.ID, "card_declined")
|
|
h.handlePaymentIntentFailed(context.Background(), evt)
|
|
|
|
db := database.GetDB()
|
|
var reloaded models.Order
|
|
if err := db.First(&reloaded, order.ID).Error; err != nil {
|
|
t.Fatalf("reload order: %v", err)
|
|
}
|
|
if reloaded.Status != models.OrderStatusPaid {
|
|
t.Fatalf("paid order was modified by payment_failed webhook: got %q", reloaded.Status)
|
|
}
|
|
}
|
|
|
|
// TestHandlePaymentIntentFailed_MissingMetadataNoOp covers the no-order
|
|
// short-circuit so the test file exercises every early-return branch in
|
|
// handlePaymentIntentFailed touched by the predicate work.
|
|
func TestHandlePaymentIntentFailed_MissingMetadataNoOp(t *testing.T) {
|
|
testDB, _ := setupCartTestDB(t)
|
|
defer func() { _ = os.Remove(testDB) }()
|
|
defer database.ResetForTesting()
|
|
|
|
pi := map[string]any{"id": "pi_test_no_meta"}
|
|
raw, err := json.Marshal(pi)
|
|
if err != nil {
|
|
t.Fatalf("marshal pi: %v", err)
|
|
}
|
|
evt := stripe.Event{
|
|
Type: "payment_intent.payment_failed",
|
|
Data: &stripe.EventData{Raw: raw},
|
|
}
|
|
|
|
h := NewWebhookHandler("", "")
|
|
h.handlePaymentIntentFailed(context.Background(), evt) // must not panic
|
|
}
|
|
|
|
func TestHandlePaymentIntentSucceeded_RequiresActionFlipsToPaid(t *testing.T) {
|
|
testDB, user := setupCartTestDB(t)
|
|
defer func() { _ = os.Remove(testDB) }()
|
|
defer database.ResetForTesting()
|
|
|
|
order := seedOrderForWebhook(t, user.ID, models.OrderStatusRequiresAction, "pi_test_req_action_paid")
|
|
|
|
pi := map[string]any{
|
|
"id": order.StripePaymentID,
|
|
"amount": order.Total,
|
|
"metadata": map[string]string{"order_id": fmt.Sprintf("%d", order.ID)},
|
|
}
|
|
raw, err := json.Marshal(pi)
|
|
if err != nil {
|
|
t.Fatalf("marshal pi: %v", err)
|
|
}
|
|
evt := stripe.Event{
|
|
Type: "payment_intent.succeeded",
|
|
Data: &stripe.EventData{Raw: raw},
|
|
}
|
|
|
|
h := NewWebhookHandler("", "")
|
|
h.handlePaymentIntentSucceeded(context.Background(), evt)
|
|
|
|
db := database.GetDB()
|
|
var reloaded models.Order
|
|
if err := db.First(&reloaded, order.ID).Error; err != nil {
|
|
t.Fatalf("reload order: %v", err)
|
|
}
|
|
if reloaded.Status != models.OrderStatusPaid {
|
|
t.Fatalf("status: want %q, got %q", models.OrderStatusPaid, reloaded.Status)
|
|
}
|
|
if reloaded.StripePaymentID != order.StripePaymentID {
|
|
t.Fatalf("stripe_payment_id: want %q, got %q", order.StripePaymentID, reloaded.StripePaymentID)
|
|
}
|
|
}
|
|
|
|
func TestHandlePaymentIntentSucceeded_AmountMismatchDoesNotPay(t *testing.T) {
|
|
testDB, user := setupCartTestDB(t)
|
|
defer func() { _ = os.Remove(testDB) }()
|
|
defer database.ResetForTesting()
|
|
|
|
order := seedOrderForWebhook(t, user.ID, models.OrderStatusRequiresAction, "pi_test_amount_mismatch")
|
|
|
|
// PI claims 1 cent; seed order.Total is 500.
|
|
pi := map[string]any{
|
|
"id": order.StripePaymentID,
|
|
"amount": 1,
|
|
"metadata": map[string]string{"order_id": fmt.Sprintf("%d", order.ID)},
|
|
}
|
|
raw, err := json.Marshal(pi)
|
|
if err != nil {
|
|
t.Fatalf("marshal pi: %v", err)
|
|
}
|
|
evt := stripe.Event{
|
|
Type: "payment_intent.succeeded",
|
|
Data: &stripe.EventData{Raw: raw},
|
|
}
|
|
|
|
h := NewWebhookHandler("", "")
|
|
h.handlePaymentIntentSucceeded(context.Background(), evt)
|
|
|
|
db := database.GetDB()
|
|
var reloaded models.Order
|
|
if err := db.First(&reloaded, order.ID).Error; err != nil {
|
|
t.Fatalf("reloaded order: %v", err)
|
|
}
|
|
if reloaded.Status == models.OrderStatusPaid {
|
|
t.Fatalf("order marked paid despite amount mismatch")
|
|
}
|
|
}
|