This commit is contained in:
Tomas Dvorak
2026-01-26 08:13:18 +01:00
parent aa036b6550
commit dfc079288f
505 changed files with 95755 additions and 5712 deletions
@@ -0,0 +1,574 @@
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")
}
}