158 lines
4.8 KiB
Go
158 lines
4.8 KiB
Go
|
|
//go:build integration
|
||
|
|
// +build integration
|
||
|
|
|
||
|
|
package integration
|
||
|
|
|
||
|
|
import (
|
||
|
|
"bytes"
|
||
|
|
"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/models"
|
||
|
|
"veza-backend-api/internal/services"
|
||
|
|
)
|
||
|
|
|
||
|
|
func setupWebhookIdempotencyRouter(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())
|
||
|
|
|
||
|
|
apiRouter := api.NewAPIRouter(vezaDB, cfg)
|
||
|
|
require.NoError(t, apiRouter.Setup(router))
|
||
|
|
return router
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestWebhookIdempotency_ThreeIdenticalWebhooks_CreatesOneOrder verifies that sending
|
||
|
|
// the same payment_succeeded webhook 3 times results in 1 completed order and 1 license.
|
||
|
|
func TestWebhookIdempotency_ThreeIdenticalWebhooks_CreatesOneOrder(t *testing.T) {
|
||
|
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||
|
|
require.NoError(t, err)
|
||
|
|
// Ensure single connection for sqlite :memory: (each new connection gets empty DB)
|
||
|
|
sqlDB, _ := db.DB()
|
||
|
|
sqlDB.SetMaxOpenConns(1)
|
||
|
|
require.NoError(t, db.AutoMigrate(
|
||
|
|
&models.User{},
|
||
|
|
&models.Track{},
|
||
|
|
&marketplace.Product{},
|
||
|
|
&marketplace.Order{},
|
||
|
|
&marketplace.OrderItem{},
|
||
|
|
&marketplace.License{},
|
||
|
|
&marketplace.SellerTransfer{},
|
||
|
|
))
|
||
|
|
|
||
|
|
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)
|
||
|
|
|
||
|
|
order := &marketplace.Order{
|
||
|
|
ID: uuid.New(),
|
||
|
|
BuyerID: buyerID,
|
||
|
|
TotalAmount: 9.99,
|
||
|
|
Currency: "EUR",
|
||
|
|
Status: "pending",
|
||
|
|
HyperswitchPaymentID: "pay_idem_123",
|
||
|
|
}
|
||
|
|
require.NoError(t, db.Create(order).Error)
|
||
|
|
require.NoError(t, db.Create(&marketplace.OrderItem{
|
||
|
|
ID: uuid.New(),
|
||
|
|
OrderID: order.ID,
|
||
|
|
ProductID: product.ID,
|
||
|
|
Price: 9.99,
|
||
|
|
}).Error)
|
||
|
|
|
||
|
|
storageService := services.NewTrackStorageService("uploads/test", false, zap.NewNop())
|
||
|
|
marketService := marketplace.NewService(db, zap.NewNop(), storageService)
|
||
|
|
|
||
|
|
router := setupWebhookIdempotencyRouter(t, db, marketService)
|
||
|
|
|
||
|
|
payload, _ := json.Marshal(map[string]string{
|
||
|
|
"payment_id": "pay_idem_123",
|
||
|
|
"status": "succeeded",
|
||
|
|
})
|
||
|
|
signature := computeWebhookSignature(payload, "test-secret-at-least-32-chars-long")
|
||
|
|
|
||
|
|
for i := 0; i < 3; i++ {
|
||
|
|
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 call %d must return 200", i+1)
|
||
|
|
}
|
||
|
|
|
||
|
|
var orders []marketplace.Order
|
||
|
|
require.NoError(t, db.Where("hyperswitch_payment_id = ?", "pay_idem_123").Find(&orders).Error)
|
||
|
|
assert.Len(t, orders, 1, "must have exactly 1 order")
|
||
|
|
assert.Equal(t, "completed", orders[0].Status, "order must be completed")
|
||
|
|
|
||
|
|
var licenses []marketplace.License
|
||
|
|
require.NoError(t, db.Where("order_id = ?", order.ID).Find(&licenses).Error)
|
||
|
|
assert.Len(t, licenses, 1, "must have exactly 1 license")
|
||
|
|
}
|