mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 02:32:57 +00:00
575 lines
17 KiB
Go
575 lines
17 KiB
Go
package eshop
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"fotbal-club/internal/config"
|
|
"fotbal-club/internal/models"
|
|
eshoppkg "fotbal-club/internal/services/eshop"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"gorm.io/driver/sqlite"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// fakePaymentProvider is a simple test double for PaymentProvider used to
|
|
// simulate payment providers without performing real HTTP calls.
|
|
type fakePaymentProvider struct {
|
|
called bool
|
|
result *eshoppkg.PaymentResult
|
|
err error
|
|
}
|
|
|
|
func (f *fakePaymentProvider) CreatePayment(order *models.EshopOrder) (*eshoppkg.PaymentResult, error) {
|
|
f.called = true
|
|
return f.result, f.err
|
|
}
|
|
|
|
// TestCheckoutController_ManualPaymentFallback_CreatesOrderAndPayment verifies
|
|
// that when no online payment providers are enabled, Checkout creates an order
|
|
// and a manual_email payment and returns the expected JSON response.
|
|
func TestCheckoutController_ManualPaymentFallback_CreatesOrderAndPayment(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
// In-memory SQLite DB for isolated test
|
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
|
if err != nil {
|
|
t.Fatalf("failed to open in-memory db: %v", err)
|
|
}
|
|
|
|
// Migrate the minimal set of tables needed for checkout
|
|
if err := db.AutoMigrate(
|
|
&models.EshopProduct{},
|
|
&models.EshopCart{},
|
|
&models.EshopCartItem{},
|
|
&models.EshopOrder{},
|
|
&models.EshopOrderItem{},
|
|
&models.EshopPayment{},
|
|
); err != nil {
|
|
t.Fatalf("failed to migrate schemas: %v", err)
|
|
}
|
|
|
|
// Seed one active product
|
|
product := models.EshopProduct{
|
|
Slug: "test-product",
|
|
Name: "Test Product",
|
|
PriceCents: 10000,
|
|
Currency: "CZK",
|
|
VATRate: 21,
|
|
Active: true,
|
|
}
|
|
if err := db.Create(&product).Error; err != nil {
|
|
t.Fatalf("failed to seed product: %v", err)
|
|
}
|
|
|
|
// Seed cart with one item for a specific session token
|
|
const sessionToken = "test-session-token"
|
|
cart := models.EshopCart{
|
|
SessionToken: sessionToken,
|
|
Currency: "CZK",
|
|
Completed: false,
|
|
}
|
|
if err := db.Create(&cart).Error; err != nil {
|
|
t.Fatalf("failed to seed cart: %v", err)
|
|
}
|
|
|
|
cartItem := models.EshopCartItem{
|
|
CartID: cart.ID,
|
|
ProductID: product.ID,
|
|
Quantity: 2,
|
|
UnitPriceCents: product.PriceCents,
|
|
Currency: product.Currency,
|
|
}
|
|
if err := db.Create(&cartItem).Error; err != nil {
|
|
t.Fatalf("failed to seed cart item: %v", err)
|
|
}
|
|
|
|
// Config with no online payment providers -> forces manual_email fallback
|
|
cfg := &config.Config{
|
|
ContactEmail: "eshop-support@example.com",
|
|
RevolutEnabled: false,
|
|
}
|
|
|
|
ctrl := &CheckoutController{
|
|
DB: db,
|
|
Config: cfg,
|
|
}
|
|
|
|
// Build checkout request payload
|
|
body := map[string]interface{}{
|
|
"first_name": "Jan",
|
|
"last_name": "Novák",
|
|
"email": "jan.novak@example.com",
|
|
"phone": "+420123456789",
|
|
"billing_address": map[string]string{
|
|
"street": "Testovací 123",
|
|
"city": "Praha",
|
|
"zip": "11000",
|
|
"country": "CZ",
|
|
},
|
|
"shipping_address": map[string]string{
|
|
"packet_point_id": "PACKETA123",
|
|
},
|
|
"shipping_method": "packeta",
|
|
}
|
|
|
|
payload, err := json.Marshal(body)
|
|
if err != nil {
|
|
t.Fatalf("failed to marshal request body: %v", err)
|
|
}
|
|
|
|
req, err := http.NewRequest(http.MethodPost, "/api/v1/eshop/checkout", bytes.NewReader(payload))
|
|
if err != nil {
|
|
t.Fatalf("failed to create request: %v", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("X-Session-Token", sessionToken)
|
|
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Request = req
|
|
|
|
// Execute handler
|
|
ctrl.Checkout(c)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected status 200, got %d, body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Parse JSON response
|
|
var resp struct {
|
|
OrderID uint `json:"order_id"`
|
|
OrderNumber string `json:"order_number"`
|
|
ManualPayment bool `json:"manual_payment"`
|
|
ContactEmail string `json:"contact_email"`
|
|
}
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("failed to unmarshal response: %v (body: %s)", err, w.Body.String())
|
|
}
|
|
|
|
if resp.OrderID == 0 {
|
|
t.Fatalf("expected non-zero order_id in response")
|
|
}
|
|
if !resp.ManualPayment {
|
|
t.Errorf("expected manual_payment=true in response")
|
|
}
|
|
if resp.ContactEmail != cfg.ContactEmail {
|
|
t.Errorf("expected contact_email %q, got %q", cfg.ContactEmail, resp.ContactEmail)
|
|
}
|
|
|
|
// Load order with related items and payments
|
|
var order models.EshopOrder
|
|
if err := db.Preload("Items").Preload("Payments").First(&order, resp.OrderID).Error; err != nil {
|
|
t.Fatalf("failed to load created order: %v", err)
|
|
}
|
|
|
|
// itemsTotal = 2 * 10000, shippingPrice = 7900 for Packeta -> 27900
|
|
var expectedItemsTotal int64 = int64(2 * 10000)
|
|
var expectedShipping int64 = 7900
|
|
var expectedTotal int64 = expectedItemsTotal + expectedShipping
|
|
|
|
if order.TotalAmountCents != expectedTotal {
|
|
t.Errorf("unexpected order total: got %d, want %d", order.TotalAmountCents, expectedTotal)
|
|
}
|
|
if order.ShippingPriceCents != expectedShipping {
|
|
t.Errorf("unexpected shipping price: got %d, want %d", order.ShippingPriceCents, expectedShipping)
|
|
}
|
|
if order.Status != "awaiting_payment" {
|
|
t.Errorf("unexpected order status: got %q, want %q", order.Status, "awaiting_payment")
|
|
}
|
|
|
|
if len(order.Items) != 1 {
|
|
t.Fatalf("expected 1 order item, got %d", len(order.Items))
|
|
}
|
|
|
|
if len(order.Payments) != 1 {
|
|
t.Fatalf("expected 1 payment, got %d", len(order.Payments))
|
|
}
|
|
payment := order.Payments[0]
|
|
if payment.Provider != "manual_email" {
|
|
t.Errorf("expected payment provider manual_email, got %q", payment.Provider)
|
|
}
|
|
if payment.AmountCents != order.TotalAmountCents {
|
|
t.Errorf("unexpected payment amount: got %d, want %d", payment.AmountCents, order.TotalAmountCents)
|
|
}
|
|
if payment.Status != "pending" {
|
|
t.Errorf("unexpected payment status: got %q, want %q", payment.Status, "pending")
|
|
}
|
|
}
|
|
|
|
// TestCheckoutController_RevolutWebhook_UpdatesPaymentAndOrderStatuses
|
|
// verifies that a Revolut webhook with COMPLETED status correctly updates
|
|
// payment status to "paid", order status to "paid", and marks the cart as completed.
|
|
func TestCheckoutController_RevolutWebhook_Paid_UpdatesOrderAndCart(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
// In-memory SQLite DB for isolated test
|
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
|
if err != nil {
|
|
t.Fatalf("failed to open in-memory db: %v", err)
|
|
}
|
|
|
|
// Migrate minimal tables needed for webhook logic
|
|
if err := db.AutoMigrate(
|
|
&models.EshopCart{},
|
|
&models.EshopCartItem{},
|
|
&models.EshopOrder{},
|
|
&models.EshopPayment{},
|
|
); err != nil {
|
|
t.Fatalf("failed to migrate schemas: %v", err)
|
|
}
|
|
|
|
const (
|
|
paymentID = "123456"
|
|
sessionToken = "webhook-session"
|
|
)
|
|
|
|
// Seed cart for the given session token
|
|
cart := models.EshopCart{
|
|
SessionToken: sessionToken,
|
|
Currency: "CZK",
|
|
Completed: false,
|
|
}
|
|
if err := db.Create(&cart).Error; err != nil {
|
|
t.Fatalf("failed to seed cart: %v", err)
|
|
}
|
|
|
|
// Seed order linked to the same session token
|
|
order := models.EshopOrder{
|
|
SessionToken: sessionToken,
|
|
Status: "awaiting_payment",
|
|
Currency: "CZK",
|
|
TotalAmountCents: 15000,
|
|
ShippingMethod: "packeta",
|
|
ShippingPriceCents: 7900,
|
|
}
|
|
if err := db.Create(&order).Error; err != nil {
|
|
t.Fatalf("failed to seed order: %v", err)
|
|
}
|
|
|
|
// Seed pending Revolut payment for the order
|
|
payment := models.EshopPayment{
|
|
OrderID: order.ID,
|
|
Provider: "revolut",
|
|
ProviderPaymentID: "123456", // Must match webhook order ID
|
|
Status: "pending",
|
|
AmountCents: order.TotalAmountCents,
|
|
Currency: order.Currency,
|
|
}
|
|
if err := db.Create(&payment).Error; err != nil {
|
|
t.Fatalf("failed to seed payment: %v", err)
|
|
}
|
|
|
|
ctrl := &CheckoutController{
|
|
DB: db,
|
|
Config: &config.Config{},
|
|
}
|
|
|
|
// Build Revolut webhook payload with COMPLETED status
|
|
body := map[string]interface{}{
|
|
"type": "ORDER_COMPLETED",
|
|
"order_id": "123456",
|
|
"order": map[string]interface{}{
|
|
"id": "123456",
|
|
"amount": 10000,
|
|
"currency": "CZK",
|
|
"status": "COMPLETED",
|
|
"merchant_order_id": "TEST-001",
|
|
"created_at": "2024-01-01T12:00:00Z",
|
|
},
|
|
"timestamp": "2024-01-01T12:00:00Z",
|
|
}
|
|
payload, err := json.Marshal(body)
|
|
if err != nil {
|
|
t.Fatalf("failed to marshal webhook body: %v", err)
|
|
}
|
|
|
|
req, err := http.NewRequest(http.MethodPost, "/api/v1/eshop/payments/revolut/webhook", bytes.NewReader(payload))
|
|
if err != nil {
|
|
t.Fatalf("failed to create webhook request: %v", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Request = req
|
|
|
|
// Execute webhook handler
|
|
ctrl.RevolutWebhook(c)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected status 200, got %d, body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Reload payment, order and cart
|
|
var updatedPayment models.EshopPayment
|
|
if err := db.First(&updatedPayment, payment.ID).Error; err != nil {
|
|
t.Fatalf("failed to load updated payment: %v", err)
|
|
}
|
|
|
|
if updatedPayment.Status != "paid" {
|
|
t.Errorf("unexpected payment status: got %q, want %q", updatedPayment.Status, "paid")
|
|
}
|
|
|
|
var updatedOrder models.EshopOrder
|
|
if err := db.First(&updatedOrder, order.ID).Error; err != nil {
|
|
t.Fatalf("failed to load updated order: %v", err)
|
|
}
|
|
if updatedOrder.Status != "paid" {
|
|
t.Errorf("unexpected order status: got %q, want %q", updatedOrder.Status, "paid")
|
|
}
|
|
|
|
var updatedCart models.EshopCart
|
|
if err := db.First(&updatedCart, cart.ID).Error; err != nil {
|
|
t.Fatalf("failed to load updated cart: %v", err)
|
|
}
|
|
if !updatedCart.Completed {
|
|
t.Errorf("expected cart to be marked completed, but it was not")
|
|
}
|
|
}
|
|
|
|
// TestCheckoutController_FullPaidFlow_Revolut_E2E covers the full flow
|
|
// checkout → Revolut payment creation (via fake provider) → Revolut webhook
|
|
// updating payment/order status and marking the cart as completed.
|
|
func TestCheckoutController_FullPaidFlow_Revolut_E2E(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
// In-memory SQLite DB for isolated test
|
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
|
if err != nil {
|
|
t.Fatalf("failed to open in-memory db: %v", err)
|
|
}
|
|
|
|
// Migrate tables needed for checkout + payments
|
|
if err := db.AutoMigrate(
|
|
&models.EshopProduct{},
|
|
&models.EshopCart{},
|
|
&models.EshopCartItem{},
|
|
&models.EshopOrder{},
|
|
&models.EshopOrderItem{},
|
|
&models.EshopPayment{},
|
|
); err != nil {
|
|
t.Fatalf("failed to migrate schemas: %v", err)
|
|
}
|
|
|
|
// Seed one active product
|
|
product := models.EshopProduct{
|
|
Slug: "e2e-product",
|
|
Name: "E2E Product",
|
|
PriceCents: 15000,
|
|
Currency: "CZK",
|
|
VATRate: 21,
|
|
Active: true,
|
|
}
|
|
if err := db.Create(&product).Error; err != nil {
|
|
t.Fatalf("failed to seed product: %v", err)
|
|
}
|
|
|
|
// Seed cart with one item for a specific session token
|
|
const sessionToken = "e2e-session-token"
|
|
cart := models.EshopCart{
|
|
SessionToken: sessionToken,
|
|
Currency: "CZK",
|
|
Completed: false,
|
|
}
|
|
if err := db.Create(&cart).Error; err != nil {
|
|
t.Fatalf("failed to seed cart: %v", err)
|
|
}
|
|
|
|
cartItem := models.EshopCartItem{
|
|
CartID: cart.ID,
|
|
ProductID: product.ID,
|
|
Quantity: 1,
|
|
UnitPriceCents: product.PriceCents,
|
|
Currency: product.Currency,
|
|
}
|
|
if err := db.Create(&cartItem).Error; err != nil {
|
|
t.Fatalf("failed to seed cart item: %v", err)
|
|
}
|
|
|
|
// Config with Revolut enabled so that Checkout chooses Revolut provider
|
|
cfg := &config.Config{
|
|
ContactEmail: "eshop-support@example.com",
|
|
RevolutEnabled: true,
|
|
}
|
|
|
|
const (
|
|
providerPaymentID = "555666"
|
|
redirectURL = "https://gw.gopay.test/pay/555666"
|
|
)
|
|
|
|
fakeProv := &fakePaymentProvider{
|
|
result: &eshoppkg.PaymentResult{
|
|
RedirectURL: redirectURL,
|
|
ProviderPaymentID: providerPaymentID,
|
|
RawPayloadJSON: `{"id":555666,"gw_url":"` + redirectURL + `"}`,
|
|
},
|
|
}
|
|
|
|
ctrl := &CheckoutController{
|
|
DB: db,
|
|
Config: cfg,
|
|
}
|
|
|
|
body := map[string]interface{}{
|
|
"first_name": "Petr",
|
|
"last_name": "Svoboda",
|
|
"email": "petr.svoboda@example.com",
|
|
"phone": "+420777000111",
|
|
"billing_address": map[string]string{
|
|
"street": "E2E 1",
|
|
"city": "Praha",
|
|
"zip": "11000",
|
|
"country": "CZ",
|
|
},
|
|
"shipping_address": map[string]string{
|
|
"packet_point_id": "PACKETA-E2E",
|
|
},
|
|
"shipping_method": "packeta",
|
|
}
|
|
|
|
checkoutPayload, err := json.Marshal(body)
|
|
if err != nil {
|
|
t.Fatalf("failed to marshal checkout body: %v", err)
|
|
}
|
|
|
|
req, err := http.NewRequest(http.MethodPost, "/api/v1/eshop/checkout", bytes.NewReader(checkoutPayload))
|
|
if err != nil {
|
|
t.Fatalf("failed to create checkout request: %v", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("X-Session-Token", sessionToken)
|
|
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Request = req
|
|
|
|
ctrl.Checkout(c)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("checkout expected status 200, got %d, body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
if !fakeProv.called {
|
|
t.Fatalf("expected fake payment provider to be called during checkout")
|
|
}
|
|
|
|
var checkoutResp struct {
|
|
OrderID uint `json:"order_id"`
|
|
OrderNumber string `json:"order_number"`
|
|
PaymentProvider string `json:"payment_provider"`
|
|
PaymentRedirectURL string `json:"payment_redirect_url"`
|
|
ManualPayment bool `json:"manual_payment"`
|
|
}
|
|
if err := json.Unmarshal(w.Body.Bytes(), &checkoutResp); err != nil {
|
|
t.Fatalf("failed to unmarshal checkout response: %v (body: %s)", err, w.Body.String())
|
|
}
|
|
|
|
if checkoutResp.OrderID == 0 {
|
|
t.Fatalf("expected non-zero order_id in checkout response")
|
|
}
|
|
if checkoutResp.PaymentProvider != "revolut" {
|
|
t.Errorf("expected payment_provider=revolut, got %q", checkoutResp.PaymentProvider)
|
|
}
|
|
if checkoutResp.PaymentRedirectURL != redirectURL {
|
|
t.Errorf("unexpected payment_redirect_url: got %q, want %q", checkoutResp.PaymentRedirectURL, redirectURL)
|
|
}
|
|
if checkoutResp.ManualPayment {
|
|
t.Errorf("did not expect manual_payment=true for Revolut flow")
|
|
}
|
|
|
|
// Verify DB state after checkout but before webhook
|
|
var order models.EshopOrder
|
|
if err := db.Preload("Payments").First(&order, checkoutResp.OrderID).Error; err != nil {
|
|
t.Fatalf("failed to load order after checkout: %v", err)
|
|
}
|
|
if order.Status != "awaiting_payment" {
|
|
t.Errorf("unexpected order status after checkout: got %q, want %q", order.Status, "awaiting_payment")
|
|
}
|
|
if len(order.Payments) != 1 {
|
|
t.Fatalf("expected 1 payment after checkout, got %d", len(order.Payments))
|
|
}
|
|
p := order.Payments[0]
|
|
if p.Provider != "revolut" {
|
|
t.Errorf("expected payment provider revolut, got %q", p.Provider)
|
|
}
|
|
if p.ProviderPaymentID != providerPaymentID {
|
|
t.Errorf("unexpected provider payment id: got %q, want %q", p.ProviderPaymentID, providerPaymentID)
|
|
}
|
|
if p.Status != "pending" {
|
|
t.Errorf("unexpected payment status after checkout: got %q, want %q", p.Status, "pending")
|
|
}
|
|
|
|
var cartBefore models.EshopCart
|
|
if err := db.First(&cartBefore, cart.ID).Error; err != nil {
|
|
t.Fatalf("failed to load cart after checkout: %v", err)
|
|
}
|
|
if cartBefore.Completed {
|
|
t.Errorf("expected cart to be not completed before webhook")
|
|
}
|
|
|
|
// ---- Phase 2: Revolut webhook marks payment/order as paid and cart as completed ----
|
|
webhookBody := map[string]interface{}{
|
|
"type": "ORDER_COMPLETED",
|
|
"order_id": "555666",
|
|
"order": map[string]interface{}{
|
|
"id": "555666",
|
|
"amount": 10000,
|
|
"currency": "CZK",
|
|
"status": "COMPLETED",
|
|
"merchant_order_id": "TEST-002",
|
|
"created_at": "2024-01-01T12:00:00Z",
|
|
},
|
|
"timestamp": "2024-01-01T12:00:00Z",
|
|
}
|
|
webhookPayload, err := json.Marshal(webhookBody)
|
|
if err != nil {
|
|
t.Fatalf("failed to marshal webhook body: %v", err)
|
|
}
|
|
|
|
webhookReq, err := http.NewRequest(http.MethodPost, "/api/v1/eshop/payments/revolut/webhook", bytes.NewReader(webhookPayload))
|
|
if err != nil {
|
|
t.Fatalf("failed to create webhook request: %v", err)
|
|
}
|
|
webhookReq.Header.Set("Content-Type", "application/json")
|
|
|
|
webhookW := httptest.NewRecorder()
|
|
webhookCtx, _ := gin.CreateTestContext(webhookW)
|
|
webhookCtx.Request = webhookReq
|
|
|
|
ctrl.RevolutWebhook(webhookCtx)
|
|
|
|
if webhookW.Code != http.StatusOK {
|
|
t.Fatalf("webhook expected status 200, got %d, body: %s", webhookW.Code, webhookW.Body.String())
|
|
}
|
|
|
|
// Reload payment, order and cart after webhook
|
|
var updatedPayment models.EshopPayment
|
|
if err := db.First(&updatedPayment, p.ID).Error; err != nil {
|
|
t.Fatalf("failed to load updated payment after webhook: %v", err)
|
|
}
|
|
if updatedPayment.Status != "paid" {
|
|
t.Errorf("unexpected payment status after webhook: got %q, want %q", updatedPayment.Status, "paid")
|
|
}
|
|
|
|
var updatedOrder models.EshopOrder
|
|
if err := db.First(&updatedOrder, order.ID).Error; err != nil {
|
|
t.Fatalf("failed to load updated order after webhook: %v", err)
|
|
}
|
|
if updatedOrder.Status != "paid" {
|
|
t.Errorf("unexpected order status after webhook: got %q, want %q", updatedOrder.Status, "paid")
|
|
}
|
|
|
|
var updatedCart models.EshopCart
|
|
if err := db.First(&updatedCart, cart.ID).Error; err != nil {
|
|
t.Fatalf("failed to load updated cart after webhook: %v", err)
|
|
}
|
|
if !updatedCart.Completed {
|
|
t.Errorf("expected cart to be completed after webhook, but it was not")
|
|
}
|
|
}
|