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