Some checks failed
Veza CI / Backend (Go) (push) Failing after 0s
Veza CI / Rust (Stream Server) (push) Failing after 0s
Veza CI / Frontend (Web) (push) Failing after 0s
Security Scan / Secret Scanning (gitleaks) (push) Failing after 0s
Veza CI / Notify on failure (push) Failing after 0s
Day-2 cut of item B: the reversal path becomes async. Pre-v1.0.7
(and v1.0.7 day 1) the refund handler flipped seller_transfers
straight from completed to reversed without ever calling Stripe —
the ledger said "reversed" while the seller's Stripe balance still
showed the original transfer as settled. The new flow:
refund.succeeded webhook
→ reverseSellerAccounting transitions row: completed → reversal_pending
→ StripeReversalWorker (every REVERSAL_CHECK_INTERVAL, default 1m)
→ calls ReverseTransfer on Stripe
→ success: row → reversed + persist stripe_reversal_id
→ 404 already-reversed (dead code until day 3): row → reversed + log
→ 404 resource_missing (dead code until day 3): row → permanently_failed
→ transient error: stay reversal_pending, bump retry_count,
exponential backoff (base * 2^retry, capped at backoffMax)
→ retries exhausted: row → permanently_failed
→ buyer-facing refund completes immediately regardless of Stripe health
State machine enforcement:
* New `SellerTransfer.TransitionStatus(tx, to, extras)` wraps every
mutation: validates against AllowedTransferTransitions, guarded
UPDATE with WHERE status=<from> (optimistic lock semantics), no
RowsAffected = stale state / concurrent winner detected.
* processSellerTransfers no longer mutates .Status in place —
terminal status is decided before struct construction, so the
row is Created with its final state.
* transfer_retry.retryOne and admin RetryTransfer route through
TransitionStatus. Legacy direct assignment removed.
* TestNoDirectTransferStatusMutation greps the package for any
`st.Status = "..."` / `t.Status = "..."` / GORM
Model(&SellerTransfer{}).Update("status"...) outside the
allowlist and fails if found. Verified by temporarily injecting
a violation during development — test caught it as expected.
Configuration (v1.0.7 item B):
* REVERSAL_WORKER_ENABLED=true (default)
* REVERSAL_MAX_RETRIES=5 (default)
* REVERSAL_CHECK_INTERVAL=1m (default)
* REVERSAL_BACKOFF_BASE=1m (default)
* REVERSAL_BACKOFF_MAX=1h (default, caps exponential growth)
* .env.template documents TRANSFER_RETRY_* and REVERSAL_* env vars
so an ops reader can grep them.
Interface change: TransferService.ReverseTransfer(ctx,
stripe_transfer_id, amount *int64, reason) (reversalID, error)
added. All four mocks extended (process_webhook, transfer_retry,
admin_transfer_handler, payment_flow integration). amount=nil means
full reversal; v1.0.7 always passes nil (partial reversal is future
scope per axis-1 P2).
Stripe 404 disambiguation (ErrTransferAlreadyReversed /
ErrTransferNotFound) is wired in the worker as dead code — the
sentinels are declared and the worker branches on them, but
StripeConnectService.ReverseTransfer doesn't yet emit them. Day 3
will parse stripe.Error.Code and populate the sentinels; no worker
change needed at that point. Keeping the handling skeleton in day 2
so the worker's branch shape doesn't change between days and the
tests can already cover all four paths against the mock.
Worker unit tests (9 cases, all green, sqlite :memory:):
* happy path: reversal_pending → reversed + stripe_reversal_id set
* already reversed (mock returns sentinel): → reversed + log
* not found (mock returns sentinel): → permanently_failed + log
* transient 503: retry_count++, next_retry_at set with backoff,
stays reversal_pending
* backoff capped at backoffMax (verified with base=1s, max=10s,
retry_count=4 → capped at 10s not 16s)
* max retries exhausted: → permanently_failed
* legacy row with empty stripe_transfer_id: → permanently_failed,
does not call Stripe
* only picks up reversal_pending (skips all other statuses)
* respects next_retry_at (future rows skipped)
Existing test updated: TestProcessRefundWebhook_SucceededFinalizesState
now asserts the row lands at reversal_pending with next_retry_at
set (worker's responsibility to drive to reversed), not reversed.
Worker wired in cmd/api/main.go alongside TransferRetryWorker,
sharing the same StripeConnectService instance. Shutdown path
registered for graceful stop.
Cut from day 2 scope (per agreed-upon discipline), landing in day 3:
* Stripe 404 disambiguation implementation (parse error.Code)
* End-to-end smoke probe (refund → reversal_pending → worker
processes → reversed) against local Postgres + mock Stripe
* Batch-size tuning / inter-batch sleep — batchLimit=20 today is
safely under Stripe's 100 req/s default rate limit; revisit if
observed load warrants
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
274 lines
8.9 KiB
Go
274 lines
8.9 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
|
|
}
|
|
|
|
func (m *mockTransferService) ReverseTransfer(_ context.Context, _ string, _ *int64, _ string) (string, error) {
|
|
return "rev_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)
|
|
}
|