veza/veza-backend-api/internal/handlers/admin_transfer_handler_test.go
senke e0efdf8210 fix(connect): defensive empty-id guard + admin retry test asserts persistence
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>
2026-04-17 14:03:37 +02:00

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)
}