Post-A self-review surfaced two gaps: 1. `StripeConnectService.CreateTransfer` trusted Stripe's SDK to return a non-empty `tr.ID` on success (`err == nil`). The invariant holds in practice, but an empty id silently persisted on a completed transfer leaves the row permanently un-reversible — which defeats the entire point of item A. Added a belt-and-suspenders check that converts `(tr.ID="", err=nil)` into a failed transfer. 2. `TestRetryTransfer_Success` (admin handler) exercised the retry path but didn't assert that StripeTransferID was persisted after a successful retry. The worker path and processSellerTransfers both had the assertion; the admin manual-retry path was the third entry into the same behavior and lacked coverage. Added the assertion. Decision on scope: v1.0.6.2 added a partial UNIQUE on stripe_transfer_id (WHERE IS NOT NULL AND <> '') in migration 981, matching the v1.0.6.1 pattern for refunds.hyperswitch_refund_id. The combination of (a) the DB partial UNIQUE and (b) this defensive guard means there is now no code or data path that can persist an empty transfer id while claiming success. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
246 lines
7.1 KiB
Go
246 lines
7.1 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"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/core/marketplace"
|
|
"veza-backend-api/internal/models"
|
|
)
|
|
|
|
func setupAdminTransferTestDB(t *testing.T) *gorm.DB {
|
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
|
require.NoError(t, err)
|
|
require.NoError(t, db.AutoMigrate(
|
|
&models.User{},
|
|
&marketplace.SellerTransfer{},
|
|
))
|
|
return db
|
|
}
|
|
|
|
type mockTransferServiceAdmin struct {
|
|
err error
|
|
stripeTransferID string
|
|
}
|
|
|
|
func (m *mockTransferServiceAdmin) CreateTransfer(_ context.Context, _ uuid.UUID, _ int64, _, _ string) (string, error) {
|
|
if m.err != nil {
|
|
return "", m.err
|
|
}
|
|
id := m.stripeTransferID
|
|
if id == "" {
|
|
id = "tr_mock"
|
|
}
|
|
return id, nil
|
|
}
|
|
|
|
func TestGetTransfers_ReturnsAll(t *testing.T) {
|
|
db := setupAdminTransferTestDB(t)
|
|
logger := zap.NewNop()
|
|
handler := NewAdminTransferHandler(db, nil, 0.10, logger)
|
|
|
|
sellerID := uuid.New()
|
|
orderID := uuid.New()
|
|
require.NoError(t, db.Create(&marketplace.SellerTransfer{
|
|
ID: uuid.New(),
|
|
SellerID: sellerID,
|
|
OrderID: orderID,
|
|
AmountCents: 1000,
|
|
PlatformFeeCents: 100,
|
|
Currency: "EUR",
|
|
Status: "completed",
|
|
}).Error)
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Request = httptest.NewRequest(http.MethodGet, "/admin/transfers", nil)
|
|
|
|
handler.GetTransfers(c)
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
var body map[string]interface{}
|
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
|
assert.True(t, body["success"].(bool))
|
|
data := body["data"].(map[string]interface{})
|
|
transfers := data["transfers"].([]interface{})
|
|
assert.Len(t, transfers, 1)
|
|
assert.Equal(t, float64(1), float64(data["total"].(float64)))
|
|
}
|
|
|
|
func TestGetTransfers_FilterByStatus(t *testing.T) {
|
|
db := setupAdminTransferTestDB(t)
|
|
logger := zap.NewNop()
|
|
handler := NewAdminTransferHandler(db, nil, 0.10, logger)
|
|
|
|
sellerID := uuid.New()
|
|
require.NoError(t, db.Create(&marketplace.SellerTransfer{
|
|
ID: uuid.New(),
|
|
SellerID: sellerID,
|
|
OrderID: uuid.New(),
|
|
AmountCents: 1000,
|
|
PlatformFeeCents: 100,
|
|
Currency: "EUR",
|
|
Status: "completed",
|
|
}).Error)
|
|
require.NoError(t, db.Create(&marketplace.SellerTransfer{
|
|
ID: uuid.New(),
|
|
SellerID: sellerID,
|
|
OrderID: uuid.New(),
|
|
AmountCents: 500,
|
|
PlatformFeeCents: 50,
|
|
Currency: "EUR",
|
|
Status: "failed",
|
|
}).Error)
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Request = httptest.NewRequest(http.MethodGet, "/admin/transfers?status=failed", nil)
|
|
|
|
handler.GetTransfers(c)
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
var body map[string]interface{}
|
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
|
data := body["data"].(map[string]interface{})
|
|
transfers := data["transfers"].([]interface{})
|
|
assert.Len(t, transfers, 1)
|
|
assert.Equal(t, float64(1), float64(data["total"].(float64)))
|
|
}
|
|
|
|
func TestGetTransfers_FilterBySeller(t *testing.T) {
|
|
db := setupAdminTransferTestDB(t)
|
|
logger := zap.NewNop()
|
|
handler := NewAdminTransferHandler(db, nil, 0.10, logger)
|
|
|
|
seller1 := uuid.New()
|
|
seller2 := uuid.New()
|
|
require.NoError(t, db.Create(&marketplace.SellerTransfer{
|
|
ID: uuid.New(),
|
|
SellerID: seller1,
|
|
OrderID: uuid.New(),
|
|
AmountCents: 1000,
|
|
PlatformFeeCents: 100,
|
|
Currency: "EUR",
|
|
Status: "completed",
|
|
}).Error)
|
|
require.NoError(t, db.Create(&marketplace.SellerTransfer{
|
|
ID: uuid.New(),
|
|
SellerID: seller2,
|
|
OrderID: uuid.New(),
|
|
AmountCents: 500,
|
|
PlatformFeeCents: 50,
|
|
Currency: "EUR",
|
|
Status: "completed",
|
|
}).Error)
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Request = httptest.NewRequest(http.MethodGet, "/admin/transfers?seller_id="+seller1.String(), nil)
|
|
|
|
handler.GetTransfers(c)
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
var body map[string]interface{}
|
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
|
data := body["data"].(map[string]interface{})
|
|
transfers := data["transfers"].([]interface{})
|
|
assert.Len(t, transfers, 1)
|
|
}
|
|
|
|
func TestRetryTransfer_Success(t *testing.T) {
|
|
db := setupAdminTransferTestDB(t)
|
|
logger := zap.NewNop()
|
|
mock := &mockTransferServiceAdmin{stripeTransferID: "tr_admin_retry_ok"}
|
|
handler := NewAdminTransferHandler(db, mock, 0.10, logger)
|
|
|
|
transferID := uuid.New()
|
|
sellerID := uuid.New()
|
|
orderID := uuid.New()
|
|
require.NoError(t, db.Create(&marketplace.SellerTransfer{
|
|
ID: transferID,
|
|
SellerID: sellerID,
|
|
OrderID: orderID,
|
|
AmountCents: 900,
|
|
PlatformFeeCents: 100,
|
|
Currency: "EUR",
|
|
Status: "failed",
|
|
}).Error)
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Request = httptest.NewRequest(http.MethodPost, "/admin/transfers/"+transferID.String()+"/retry", nil)
|
|
c.Params = gin.Params{{Key: "id", Value: transferID.String()}}
|
|
|
|
handler.RetryTransfer(c)
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
var updated marketplace.SellerTransfer
|
|
require.NoError(t, db.First(&updated, transferID).Error)
|
|
assert.Equal(t, "completed", updated.Status)
|
|
// v1.0.7 item A: admin-triggered retry also persists the Stripe
|
|
// transfer id. The worker and processSellerTransfers paths are
|
|
// already covered by their own tests; this is the third path into
|
|
// the same behavior.
|
|
assert.Equal(t, "tr_admin_retry_ok", updated.StripeTransferID)
|
|
}
|
|
|
|
func TestRetryTransfer_NotFailed(t *testing.T) {
|
|
db := setupAdminTransferTestDB(t)
|
|
logger := zap.NewNop()
|
|
mock := &mockTransferServiceAdmin{}
|
|
handler := NewAdminTransferHandler(db, mock, 0.10, logger)
|
|
|
|
transferID := uuid.New()
|
|
require.NoError(t, db.Create(&marketplace.SellerTransfer{
|
|
ID: transferID,
|
|
SellerID: uuid.New(),
|
|
OrderID: uuid.New(),
|
|
AmountCents: 900,
|
|
PlatformFeeCents: 100,
|
|
Currency: "EUR",
|
|
Status: "completed",
|
|
}).Error)
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Request = httptest.NewRequest(http.MethodPost, "/admin/transfers/"+transferID.String()+"/retry", nil)
|
|
c.Params = gin.Params{{Key: "id", Value: transferID.String()}}
|
|
|
|
handler.RetryTransfer(c)
|
|
|
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
|
}
|
|
|
|
func TestRetryTransfer_NotFound(t *testing.T) {
|
|
db := setupAdminTransferTestDB(t)
|
|
logger := zap.NewNop()
|
|
mock := &mockTransferServiceAdmin{}
|
|
handler := NewAdminTransferHandler(db, mock, 0.10, logger)
|
|
|
|
nonexistentID := uuid.New()
|
|
gin.SetMode(gin.TestMode)
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Request = httptest.NewRequest(http.MethodPost, "/admin/transfers/"+nonexistentID.String()+"/retry", nil)
|
|
c.Params = gin.Params{{Key: "id", Value: nonexistentID.String()}}
|
|
|
|
handler.RetryTransfer(c)
|
|
|
|
assert.Equal(t, http.StatusNotFound, w.Code)
|
|
}
|