veza/veza-backend-api/tests/integration/payment_flow_test.go
senke 2583c92d4f
Some checks failed
Veza CI / Backend (Go) (push) Failing after 0s
Veza CI / Frontend (Web) (push) Failing after 0s
Veza CI / Rust (Stream Server) (push) Failing after 0s
Security Scan / Secret Scanning (gitleaks) (push) Failing after 0s
Veza CI / Notify on failure (push) Failing after 0s
refactor(connect): persist stripe_transfer_id on create + retry — v1.0.7 item A
TransferService.CreateTransfer signature changes from (...) error to
(...) (string, error) — the caller now captures the Stripe transfer
identifier and persists it on the SellerTransfer row. Pre-v1.0.7 the
stripe_transfer_id column was declared on the model and table but
never written to, which blocked the reversal worker (v1.0.7 item B)
from identifying which transfer to reverse on refund.

Changes:
  * `TransferService` interface and `StripeConnectService.CreateTransfer`
    both return the Stripe transfer id alongside the error.
  * `processSellerTransfers` (marketplace service) persists the id on
    success before `tx.Create(&st)` so a crash between Stripe ACK and
    DB commit leaves no inconsistency.
  * `TransferRetryWorker.retryOne` persists on retry success — a row
    that failed on first attempt and succeeded via the worker is
    reversal-ready all the same.
  * `admin_transfer_handler.RetryTransfer` (manual retry) persists too.
  * `SellerPayout.ExternalPayoutID` is populated by the Connect payout
    flow (`payout.go`) — the field existed but was never written.
  * Four test mocks updated; two tests assert the id is persisted on
    the happy path, one on the failure path confirms we don't write a
    fake id when the provider errors.

Migration `981_seller_transfers_stripe_reversal_id.sql`:
  * Adds nullable `stripe_reversal_id` column for item B.
  * Partial UNIQUE indexes on both stripe_transfer_id and
    stripe_reversal_id (WHERE IS NOT NULL AND <> ''), mirroring the
    v1.0.6.1 pattern for refunds.hyperswitch_refund_id.
  * Logs a count of historical completed transfers that lack an id —
    these are candidates for the backfill CLI follow-up task.

Backfill for historical rows is a separate follow-up (cmd/tools/
backfill_stripe_transfer_ids, calling Stripe's transfers.List with
Destination + Metadata[order_id]). Pre-v1.0.7 transfers without a
backfilled id cannot be auto-reversed on refund — document in P2.9
admin-recovery when it lands. Acceptable scope per v107-plan.

Migration number bumped 980 → 981 because v1.0.6.2 used 980 for the
unpaid-subscription cleanup; v107-plan updated with the note.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 13:08:39 +02:00

270 lines
8.8 KiB
Go

//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/middleware"
"veza-backend-api/internal/models"
"veza-backend-api/internal/services"
)
// mockPaymentProvider returns fixed payment_id for CreatePayment (no real Hyperswitch call)
type mockPaymentProvider struct {
paymentID string
clientSecret string
}
func (m *mockPaymentProvider) CreatePayment(_ context.Context, _ int64, _ string, _ string, _ string, _ map[string]string) (string, string, error) {
return m.paymentID, m.clientSecret, nil
}
func (m *mockPaymentProvider) GetPayment(_ context.Context, _ string) (string, error) {
return "succeeded", nil
}
// mockTransferService records CreateTransfer calls
type mockTransferService struct {
calls []struct {
SellerID uuid.UUID
Amount int64
Currency string
OrderID string
}
}
func (m *mockTransferService) CreateTransfer(_ context.Context, sellerUserID uuid.UUID, amount int64, currency, orderID string) (string, error) {
m.calls = append(m.calls, struct {
SellerID uuid.UUID
Amount int64
Currency string
OrderID string
}{sellerUserID, amount, currency, orderID})
return "tr_mock", nil
}
// testAuthMiddleware reads X-User-ID header and sets user_id in context (for integration tests)
type testAuthMiddleware struct{}
func (t *testAuthMiddleware) RequireAuth() gin.HandlerFunc {
return func(c *gin.Context) {
if userIDStr := c.GetHeader("X-User-ID"); userIDStr != "" {
if userID, err := uuid.Parse(userIDStr); err == nil {
c.Set("user_id", userID)
c.Next()
return
}
}
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
}
}
func (t *testAuthMiddleware) RequireContentCreatorRole() gin.HandlerFunc {
return t.RequireAuth()
}
func (t *testAuthMiddleware) RequireOwnershipOrAdmin(_ string, _ middleware.ResourceOwnerResolver) gin.HandlerFunc {
return t.RequireAuth()
}
func (t *testAuthMiddleware) RequirePermission(_ string) gin.HandlerFunc {
return t.RequireAuth()
}
func (t *testAuthMiddleware) RequireAdmin() gin.HandlerFunc {
return t.RequireAuth()
}
func (t *testAuthMiddleware) OptionalAuth() gin.HandlerFunc {
return func(c *gin.Context) { c.Next() }
}
func (t *testAuthMiddleware) RefreshToken() gin.HandlerFunc {
return func(c *gin.Context) { c.AbortWithStatus(http.StatusNotImplemented) }
}
func setupPaymentFlowRouter(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-at-least-32-chars-long",
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,
}
require.NoError(t, cfg.InitServicesForTest())
require.NoError(t, cfg.InitMiddlewaresForTest())
cfg.AuthMiddlewareOverride = &testAuthMiddleware{}
apiRouter := api.NewAPIRouter(vezaDB, cfg)
require.NoError(t, apiRouter.Setup(router))
return router
}
// TestPaymentFlow_E2E_CartCheckoutWebhook verifies the full payment flow:
// add to cart -> checkout -> webhook payment_succeeded -> order completed, license created, transfer initiated
func TestPaymentFlow_E2E_CartCheckoutWebhook(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{},
))
// CartItem uses gen_random_uuid() (PostgreSQL) — create manually for SQLite
require.NoError(t, db.Exec(`
CREATE TABLE IF NOT EXISTS cart_items (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
product_id TEXT NOT NULL,
quantity INTEGER DEFAULT 1,
created_at DATETIME,
updated_at DATETIME
)
`).Error)
db.Callback().Create().Before("gorm:create").Register("generate_uuid_cart_item", func(d *gorm.DB) {
if item, ok := d.Statement.Dest.(*marketplace.CartItem); ok && item.ID == uuid.Nil {
item.ID = uuid.New()
}
})
buyerID := uuid.New()
sellerID := uuid.New()
trackID := 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: "/test.mp3"}).Error)
product := &marketplace.Product{
ID: uuid.New(),
SellerID: sellerID,
Title: "Test Product",
Price: 9.99,
ProductType: "track",
TrackID: &trackID,
Status: marketplace.ProductStatusActive,
}
require.NoError(t, db.Create(product).Error)
mockPay := &mockPaymentProvider{paymentID: "pay_mock_123", clientSecret: "pi_secret_xxx"}
mockTransfer := &mockTransferService{}
storageService := services.NewTrackStorageService("uploads/test", false, zap.NewNop())
marketService := marketplace.NewService(db, zap.NewNop(), storageService,
marketplace.WithPaymentProvider(mockPay),
marketplace.WithHyperswitchConfig(true, "/purchases"),
marketplace.WithTransferService(mockTransfer, 0.10),
)
router := setupPaymentFlowRouter(t, db, marketService)
// Step 1: Add to cart
addCartBody, _ := json.Marshal(map[string]interface{}{
"product_id": product.ID.String(),
"quantity": 1,
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/commerce/cart/items", bytes.NewReader(addCartBody))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-User-ID", buyerID.String())
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.Equal(t, http.StatusCreated, w.Code, "add to cart must succeed: %s", w.Body.String())
// Step 2: Checkout
req = httptest.NewRequest(http.MethodPost, "/api/v1/commerce/cart/checkout", bytes.NewReader([]byte("{}")))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-User-ID", buyerID.String())
w = httptest.NewRecorder()
router.ServeHTTP(w, req)
require.Equal(t, http.StatusCreated, w.Code, "checkout must succeed: %s", w.Body.String())
var checkoutResp map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &checkoutResp))
data, ok := checkoutResp["data"].(map[string]interface{})
require.True(t, ok)
orderData, ok := data["order"].(map[string]interface{})
require.True(t, ok)
orderIDStr, ok := orderData["id"].(string)
require.True(t, ok)
assert.NotEmpty(t, data["client_secret"], "client_secret must be present")
assert.Equal(t, "pay_mock_123", data["payment_id"], "payment_id must match mock")
// Step 3: Simulate webhook payment_succeeded
payload, _ := json.Marshal(map[string]string{
"payment_id": "pay_mock_123",
"status": "succeeded",
})
signature := computeWebhookSignature(payload, "test-secret-at-least-32-chars-long")
req = httptest.NewRequest(http.MethodPost, "/api/v1/webhooks/hyperswitch", bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-webhook-signature-512", signature)
w = httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code, "webhook must succeed: %s", w.Body.String())
// Assertions: order completed, license created, transfer initiated
orderID, _ := uuid.Parse(orderIDStr)
var order marketplace.Order
require.NoError(t, db.First(&order, orderID).Error)
assert.Equal(t, "completed", order.Status)
var licenses []marketplace.License
require.NoError(t, db.Where("order_id = ?", orderID).Find(&licenses).Error)
assert.Len(t, licenses, 1, "must have 1 license")
assert.Len(t, mockTransfer.calls, 1, "transfer must be initiated")
assert.Equal(t, sellerID, mockTransfer.calls[0].SellerID)
}