2026-02-27 19:00:51 +00:00
|
|
|
//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
|
|
|
|
|
}
|
|
|
|
|
|
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 *mockPaymentProvider) CreatePayment(_ context.Context, _ string, _ int64, _ string, _ string, _ string, _ map[string]string) (string, string, error) {
|
2026-02-27 19:00:51 +00:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-17 11:08:39 +00:00
|
|
|
func (m *mockTransferService) CreateTransfer(_ context.Context, sellerUserID uuid.UUID, amount int64, currency, orderID string) (string, error) {
|
2026-02-27 19:00:51 +00:00
|
|
|
m.calls = append(m.calls, struct {
|
|
|
|
|
SellerID uuid.UUID
|
|
|
|
|
Amount int64
|
|
|
|
|
Currency string
|
|
|
|
|
OrderID string
|
|
|
|
|
}{sellerUserID, amount, currency, orderID})
|
2026-04-17 11:08:39 +00:00
|
|
|
return "tr_mock", nil
|
2026-02-27 19:00:51 +00:00
|
|
|
}
|
|
|
|
|
|
feat(marketplace): async stripe connect reversal worker — v1.0.7 item B day 2
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>
2026-04-17 13:34:29 +00:00
|
|
|
func (m *mockTransferService) ReverseTransfer(_ context.Context, _ string, _ *int64, _ string) (string, error) {
|
|
|
|
|
return "rev_mock", nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-27 19:00:51 +00:00
|
|
|
// 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",
|
2026-04-14 10:22:14 +00:00
|
|
|
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,
|
2026-02-27 19:00:51 +00:00
|
|
|
AuthRateLimitLoginAttempts: 10,
|
2026-04-14 10:22:14 +00:00
|
|
|
AuthRateLimitLoginWindow: 15,
|
2026-02-27 19:00:51 +00:00
|
|
|
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{},
|
|
|
|
|
))
|
2026-03-03 19:17:54 +00:00
|
|
|
// CartItem uses gen_random_uuid() (PostgreSQL) — create manually for SQLite
|
2026-02-27 19:00:51 +00:00
|
|
|
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)
|
|
|
|
|
}
|