veza/veza-backend-api/tests/integration/refund_flow_test.go

178 lines
5.5 KiB
Go
Raw Normal View History

//go:build integration
// +build integration
package integration
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"veza-backend-api/internal/api"
"veza-backend-api/internal/config"
"veza-backend-api/internal/core/marketplace"
"veza-backend-api/internal/database"
"veza-backend-api/internal/metrics"
"veza-backend-api/internal/models"
"veza-backend-api/internal/services"
)
// mockRefundPaymentProvider implements PaymentProvider and Refund for refund flow test
type mockRefundPaymentProvider struct {
refundErr error
}
fix(hyperswitch): idempotency-key on create-payment and create-refund — v1.0.7 item D Every outbound POST /payments and POST /refunds from the Hyperswitch client now carries an Idempotency-Key HTTP header. Key values are explicit parameters at every call site — no context-carrier magic, no auto-generation. An empty key is a loud error from the client (not silent header omission) so a future new call site that forgets to supply one fails immediately, not months later under an obscure replay scenario. Key choices, both stable across HTTP retries of the same logical call: * CreatePayment → order.ID.String() (GORM BeforeCreate populates order.ID before the PSP call in ConfirmOrder). * CreateRefund → pendingRefund.ID.String() (populated by the Phase 1 tx.Create in RefundOrder, available for the Phase 2 PSP call). Scope note (reproduced here for the next reader who grep-s the commit log for "Idempotency-Key"): Idempotency-Key covers HTTP-transport retry (TLS reconnect, proxy retry, DNS flap) within a single CreatePayment / CreateRefund invocation. It does NOT cover application-level replay (user double-click, form double-submit, retry after crash before DB write). That class of bug requires state-machine preconditions on VEZA side — already addressed by the order state machine + the handler-level guards on POST /api/v1/payments (for payments) and the partial UNIQUE on `refunds.hyperswitch_refund_id` landed in v1.0.6.1 (for refunds). Hyperswitch TTL on Idempotency-Key: typically 24h-7d server-side (verify against current PSP docs). Beyond TTL, a retry with the same key is treated as a new request. Not a concern at current volumes; document if retry logic ever extends beyond 1 hour. Explicitly out of scope: item D does NOT add application-level retry logic. The current "try once, fail loudly" behavior on PSP errors is preserved. Adding retries is a separate design exercise (backoff, max attempts, circuit breaker) not part of this commit. Interfaces changed: * hyperswitch.Client.CreatePayment(ctx, idempotencyKey, ...) * hyperswitch.Client.CreatePaymentSimple(...) convenience wrapper * hyperswitch.Client.CreateRefund(ctx, idempotencyKey, ...) * hyperswitch.Provider.CreatePayment threads through * hyperswitch.Provider.CreateRefund threads through * marketplace.PaymentProvider interface — first param after ctx * marketplace.refundProvider interface — first param after ctx Removed: * hyperswitch.Provider.Refund (zero callers, superseded by CreateRefund which returns (refund_id, status, err) and is the only method marketplace's refundProvider cares about). Tests: * Two new httptest.Server-backed tests (client_test.go) pin the Idempotency-Key header value for CreatePayment and CreateRefund. * Two new empty-key tests confirm the client errors rather than silently sending no header. * TestRefundOrder_OpensPendingRefund gains an assertion that f.provider.lastIdempotencyKey == refund.ID.String() — if a future refactor threads the key from somewhere else (paymentID, uuid.New() per call, etc.) the test fails loudly. * Four pre-existing test mocks updated for the new signature (mockRefundPaymentProvider in marketplace, mockPaymentProvider in tests/integration and tests/contract, mockRefundPayment Provider in tests/integration/refund_flow). Subscription's CreateSubscriptionPayment interface declares its own shape and has no live Hyperswitch-backed implementation today — v1.0.6.2 noted this as the payment-gate bypass surface, v1.0.7 item G will ship the real provider. When that lands, item G's implementation threads the idempotency key through in the same pattern (documented in v107-plan.md item G acceptance). CHANGELOG v1.0.7-rc1 entry updated with the full item D scope note and the "out of scope: retries" caveat. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 00:30:02 +00:00
func (m *mockRefundPaymentProvider) CreatePayment(_ context.Context, _ string, _ int64, _ string, _ string, _ string, _ map[string]string) (string, string, error) {
return "pay_refund_mock", "secret", nil
}
func (m *mockRefundPaymentProvider) GetPayment(_ context.Context, _ string) (string, error) {
return "succeeded", nil
}
fix(hyperswitch): idempotency-key on create-payment and create-refund — v1.0.7 item D Every outbound POST /payments and POST /refunds from the Hyperswitch client now carries an Idempotency-Key HTTP header. Key values are explicit parameters at every call site — no context-carrier magic, no auto-generation. An empty key is a loud error from the client (not silent header omission) so a future new call site that forgets to supply one fails immediately, not months later under an obscure replay scenario. Key choices, both stable across HTTP retries of the same logical call: * CreatePayment → order.ID.String() (GORM BeforeCreate populates order.ID before the PSP call in ConfirmOrder). * CreateRefund → pendingRefund.ID.String() (populated by the Phase 1 tx.Create in RefundOrder, available for the Phase 2 PSP call). Scope note (reproduced here for the next reader who grep-s the commit log for "Idempotency-Key"): Idempotency-Key covers HTTP-transport retry (TLS reconnect, proxy retry, DNS flap) within a single CreatePayment / CreateRefund invocation. It does NOT cover application-level replay (user double-click, form double-submit, retry after crash before DB write). That class of bug requires state-machine preconditions on VEZA side — already addressed by the order state machine + the handler-level guards on POST /api/v1/payments (for payments) and the partial UNIQUE on `refunds.hyperswitch_refund_id` landed in v1.0.6.1 (for refunds). Hyperswitch TTL on Idempotency-Key: typically 24h-7d server-side (verify against current PSP docs). Beyond TTL, a retry with the same key is treated as a new request. Not a concern at current volumes; document if retry logic ever extends beyond 1 hour. Explicitly out of scope: item D does NOT add application-level retry logic. The current "try once, fail loudly" behavior on PSP errors is preserved. Adding retries is a separate design exercise (backoff, max attempts, circuit breaker) not part of this commit. Interfaces changed: * hyperswitch.Client.CreatePayment(ctx, idempotencyKey, ...) * hyperswitch.Client.CreatePaymentSimple(...) convenience wrapper * hyperswitch.Client.CreateRefund(ctx, idempotencyKey, ...) * hyperswitch.Provider.CreatePayment threads through * hyperswitch.Provider.CreateRefund threads through * marketplace.PaymentProvider interface — first param after ctx * marketplace.refundProvider interface — first param after ctx Removed: * hyperswitch.Provider.Refund (zero callers, superseded by CreateRefund which returns (refund_id, status, err) and is the only method marketplace's refundProvider cares about). Tests: * Two new httptest.Server-backed tests (client_test.go) pin the Idempotency-Key header value for CreatePayment and CreateRefund. * Two new empty-key tests confirm the client errors rather than silently sending no header. * TestRefundOrder_OpensPendingRefund gains an assertion that f.provider.lastIdempotencyKey == refund.ID.String() — if a future refactor threads the key from somewhere else (paymentID, uuid.New() per call, etc.) the test fails loudly. * Four pre-existing test mocks updated for the new signature (mockRefundPaymentProvider in marketplace, mockPaymentProvider in tests/integration and tests/contract, mockRefundPayment Provider in tests/integration/refund_flow). Subscription's CreateSubscriptionPayment interface declares its own shape and has no live Hyperswitch-backed implementation today — v1.0.6.2 noted this as the payment-gate bypass surface, v1.0.7 item G will ship the real provider. When that lands, item G's implementation threads the idempotency key through in the same pattern (documented in v107-plan.md item G acceptance). CHANGELOG v1.0.7-rc1 entry updated with the full item D scope note and the "out of scope: retries" caveat. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 00:30:02 +00:00
func (m *mockRefundPaymentProvider) CreateRefund(_ context.Context, _ string, _ string, _ *int64, _ string) (string, string, error) {
if m.refundErr != nil {
return "", "", m.refundErr
}
return "ref_mock", "pending", nil
}
func setupRefundFlowRouter(t *testing.T, db *gorm.DB, marketService *marketplace.Service) *gin.Engine {
t.Helper()
os.Setenv("ENABLE_CLAMAV", "false")
os.Setenv("CLAMAV_REQUIRED", "false")
gin.SetMode(gin.TestMode)
router := gin.New()
sqlDB, err := db.DB()
require.NoError(t, err)
vezaDB := &database.Database{
DB: sqlDB,
GormDB: db,
Logger: zap.NewNop(),
}
cfg := &config.Config{
HyperswitchWebhookSecret: "test-secret",
JWTSecret: "test-jwt-secret-key-minimum-32-characters-long",
JWTIssuer: "veza-api",
JWTAudience: "veza-app",
Logger: zap.NewNop(),
RedisClient: nil,
ErrorMetrics: metrics.NewErrorMetrics(),
UploadDir: "uploads/test",
Env: "development",
Database: vezaDB,
CORSOrigins: []string{"*"},
HandlerTimeout: 30 * time.Second,
RateLimitLimit: 100,
RateLimitWindow: 60,
AuthRateLimitLoginAttempts: 10,
AuthRateLimitLoginWindow: 15,
MarketplaceServiceOverride: marketService,
AuthMiddlewareOverride: &testAuthMiddleware{},
}
require.NoError(t, cfg.InitServicesForTest())
require.NoError(t, cfg.InitMiddlewaresForTest())
apiRouter := api.NewAPIRouter(vezaDB, cfg)
require.NoError(t, apiRouter.Setup(router))
return router
}
// TestRefundFlow_CompletedOrder_Refund_RevokesLicense verifies: order completed -> refund request ->
// order status refunded, licence revoked.
func TestRefundFlow_CompletedOrder_Refund_RevokesLicense(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(
&models.User{},
&models.Track{},
&marketplace.Product{},
&marketplace.Order{},
&marketplace.OrderItem{},
&marketplace.License{},
&marketplace.SellerTransfer{},
))
buyerID := uuid.New()
sellerID := uuid.New()
trackID := uuid.New()
productID := uuid.New()
orderID := uuid.New()
require.NoError(t, db.Create(&models.User{ID: buyerID}).Error)
require.NoError(t, db.Create(&models.User{ID: sellerID}).Error)
require.NoError(t, db.Create(&models.Track{ID: trackID, UserID: sellerID, FilePath: "/t.mp3"}).Error)
require.NoError(t, db.Create(&marketplace.Product{
ID: productID,
SellerID: sellerID,
Title: "Test Product",
Price: 9.99,
ProductType: "track",
TrackID: &trackID,
Status: marketplace.ProductStatusActive,
}).Error)
require.NoError(t, db.Create(&marketplace.Order{
ID: orderID,
BuyerID: buyerID,
TotalAmount: 9.99,
Currency: "EUR",
Status: "completed",
HyperswitchPaymentID: "pay_refund_mock",
}).Error)
require.NoError(t, db.Create(&marketplace.OrderItem{
ID: uuid.New(),
OrderID: orderID,
ProductID: productID,
Price: 9.99,
}).Error)
require.NoError(t, db.Create(&marketplace.License{
ID: uuid.New(),
BuyerID: buyerID,
TrackID: trackID,
ProductID: productID,
OrderID: orderID,
Type: marketplace.LicenseBasic,
}).Error)
mockPay := &mockRefundPaymentProvider{}
storageService := services.NewTrackStorageService("uploads/test", false, zap.NewNop())
marketService := marketplace.NewService(db, zap.NewNop(), storageService, marketplace.WithPaymentProvider(mockPay))
router := setupRefundFlowRouter(t, db, marketService)
body, _ := json.Marshal(map[string]string{"reason": "Customer request"})
req := httptest.NewRequest(http.MethodPost, "/api/v1/marketplace/orders/"+orderID.String()+"/refund", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-User-ID", buyerID.String())
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code, "refund must succeed: %s", w.Body.String())
var order marketplace.Order
require.NoError(t, db.First(&order, orderID).Error)
assert.Equal(t, "refunded", order.Status)
var licenses []marketplace.License
require.NoError(t, db.Where("order_id = ?", orderID).Find(&licenses).Error)
require.Len(t, licenses, 1)
assert.NotNil(t, licenses[0].RevokedAt, "license must be revoked")
}