Files
manoon-headless/src/__tests__/integration/api/webhooks/saleor.test.ts
Unchained 84b85f5291 test: comprehensive test suite for Manoon storefront
Add complete testing infrastructure with Vitest:

Testing Stack:
- Vitest for unit/integration tests
- @testing-library/react for component tests
- @playwright/test for E2E (installed, ready to configure)
- MSW for API mocking

Test Coverage:
1. Webhook Handler Tests (src/__tests__/integration/api/webhooks/saleor.test.ts)
   - ORDER_CONFIRMED: Emails + analytics
   - ORDER_CREATED: No duplicates
   - ORDER_FULFILLED: Tracking info
   - ORDER_CANCELLED: Cancellation reason
   - ORDER_FULLY_PAID: Payment confirmation
   - Error handling (400/500 responses)
   - Currency handling (RSD preservation)

2. OrderNotificationService Tests
   - Email sending in all 4 languages (SR, EN, DE, FR)
   - Price formatting verification
   - Admin vs Customer templates
   - Address formatting
   - Tracking info handling

3. AnalyticsService Tests
   - Revenue tracking with correct currency
   - Duplicate prevention verification
   - Error handling (doesn't break flow)
   - Singleton pattern

4. Utility Tests
   - formatPrice: RSD, EUR, USD formatting
   - Decimal and zero handling

Fixtures:
- Realistic order data in src/__tests__/fixtures/orders.ts
- Multiple scenarios (with tracking, cancelled, etc.)

Scripts:
- npm test: Run tests in watch mode
- npm run test:run: Run once
- npm run test:coverage: Generate coverage report
- npm run test:e2e: Run Playwright tests

Coverage target: 80%+ for critical paths
2026-03-25 21:07:47 +02:00

281 lines
9.5 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
import { POST, GET } from "@/app/api/webhooks/saleor/route";
import { orderNotificationService } from "@/lib/services/OrderNotificationService";
import { analyticsService } from "@/lib/services/AnalyticsService";
import { mockOrderPayload, mockOrderWithTracking, mockOrderCancelled } from "../../../fixtures/orders";
// Mock the services
vi.mock("@/lib/services/OrderNotificationService", () => ({
orderNotificationService: {
sendOrderConfirmation: vi.fn().mockResolvedValue(undefined),
sendOrderConfirmationToAdmin: vi.fn().mockResolvedValue(undefined),
sendOrderShipped: vi.fn().mockResolvedValue(undefined),
sendOrderShippedToAdmin: vi.fn().mockResolvedValue(undefined),
sendOrderCancelled: vi.fn().mockResolvedValue(undefined),
sendOrderCancelledToAdmin: vi.fn().mockResolvedValue(undefined),
sendOrderPaid: vi.fn().mockResolvedValue(undefined),
sendOrderPaidToAdmin: vi.fn().mockResolvedValue(undefined),
},
}));
vi.mock("@/lib/services/AnalyticsService", () => ({
analyticsService: {
trackOrderReceived: vi.fn().mockResolvedValue(undefined),
trackRevenue: vi.fn().mockResolvedValue(undefined),
track: vi.fn().mockResolvedValue(undefined),
},
}));
describe("Saleor Webhook Handler", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("GET /api/webhooks/saleor", () => {
it("should return health check response", async () => {
const response = await GET();
const data = await response.json();
expect(response.status).toBe(200);
expect(data.status).toBe("ok");
expect(data.supportedEvents).toContain("ORDER_CONFIRMED");
expect(data.supportedEvents).toContain("ORDER_CREATED");
});
});
describe("POST /api/webhooks/saleor - ORDER_CONFIRMED", () => {
it("should process ORDER_CONFIRMED and send customer + admin emails", async () => {
const request = new NextRequest("http://localhost:3000/api/webhooks/saleor", {
method: "POST",
headers: {
"saleor-event": "ORDER_CONFIRMED",
"saleor-domain": "api.manoonoils.com",
},
body: JSON.stringify([mockOrderPayload]),
});
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.success).toBe(true);
// Should send customer email
expect(orderNotificationService.sendOrderConfirmation).toHaveBeenCalledTimes(1);
// Should send admin email
expect(orderNotificationService.sendOrderConfirmationToAdmin).toHaveBeenCalledTimes(1);
// Should track analytics
expect(analyticsService.trackOrderReceived).toHaveBeenCalledTimes(1);
expect(analyticsService.trackRevenue).toHaveBeenCalledTimes(1);
// Verify revenue tracking has correct data
expect(analyticsService.trackRevenue).toHaveBeenCalledWith({
amount: 10000,
currency: "RSD",
orderId: mockOrderPayload.id,
orderNumber: String(mockOrderPayload.number),
});
});
it("should NOT track analytics for ORDER_CREATED (prevents duplication)", async () => {
const request = new NextRequest("http://localhost:3000/api/webhooks/saleor", {
method: "POST",
headers: {
"saleor-event": "ORDER_CREATED",
"saleor-domain": "api.manoonoils.com",
},
body: JSON.stringify([mockOrderPayload]),
});
const response = await POST(request);
expect(response.status).toBe(200);
// Should NOT send customer email
expect(orderNotificationService.sendOrderConfirmation).not.toHaveBeenCalled();
// Should NOT track analytics
expect(analyticsService.trackOrderReceived).not.toHaveBeenCalled();
expect(analyticsService.trackRevenue).not.toHaveBeenCalled();
// Should still send admin notification
expect(orderNotificationService.sendOrderConfirmationToAdmin).toHaveBeenCalledTimes(1);
});
});
describe("POST /api/webhooks/saleor - ORDER_FULFILLED", () => {
it("should send shipping emails with tracking info", async () => {
const request = new NextRequest("http://localhost:3000/api/webhooks/saleor", {
method: "POST",
headers: {
"saleor-event": "ORDER_FULFILLED",
"saleor-domain": "api.manoonoils.com",
},
body: JSON.stringify([mockOrderWithTracking]),
});
const response = await POST(request);
expect(response.status).toBe(200);
expect(orderNotificationService.sendOrderShipped).toHaveBeenCalledWith(
expect.any(Object),
"TRK123456789",
"https://tracking.example.com/TRK123456789"
);
expect(orderNotificationService.sendOrderShippedToAdmin).toHaveBeenCalledWith(
expect.any(Object),
"TRK123456789",
"https://tracking.example.com/TRK123456789"
);
});
});
describe("POST /api/webhooks/saleor - ORDER_CANCELLED", () => {
it("should send cancellation emails with reason", async () => {
const request = new NextRequest("http://localhost:3000/api/webhooks/saleor", {
method: "POST",
headers: {
"saleor-event": "ORDER_CANCELLED",
"saleor-domain": "api.manoonoils.com",
},
body: JSON.stringify([mockOrderCancelled]),
});
const response = await POST(request);
expect(response.status).toBe(200);
expect(orderNotificationService.sendOrderCancelled).toHaveBeenCalledWith(
expect.any(Object),
"Customer requested cancellation"
);
expect(orderNotificationService.sendOrderCancelledToAdmin).toHaveBeenCalledWith(
expect.any(Object),
"Customer requested cancellation"
);
});
});
describe("POST /api/webhooks/saleor - ORDER_FULLY_PAID", () => {
it("should send payment confirmation emails", async () => {
const request = new NextRequest("http://localhost:3000/api/webhooks/saleor", {
method: "POST",
headers: {
"saleor-event": "ORDER_FULLY_PAID",
"saleor-domain": "api.manoonoils.com",
},
body: JSON.stringify([mockOrderPayload]),
});
const response = await POST(request);
expect(response.status).toBe(200);
expect(orderNotificationService.sendOrderPaid).toHaveBeenCalledTimes(1);
expect(orderNotificationService.sendOrderPaidToAdmin).toHaveBeenCalledTimes(1);
});
});
describe("Error Handling", () => {
it("should return 400 for missing order in payload", async () => {
const request = new NextRequest("http://localhost:3000/api/webhooks/saleor", {
method: "POST",
headers: {
"saleor-event": "ORDER_CONFIRMED",
"saleor-domain": "api.manoonoils.com",
},
body: JSON.stringify([]), // Empty array
});
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.error).toBe("No order in payload");
});
it("should return 400 for missing saleor-event header", async () => {
const request = new NextRequest("http://localhost:3000/api/webhooks/saleor", {
method: "POST",
headers: {
"saleor-domain": "api.manoonoils.com",
},
body: JSON.stringify([mockOrderPayload]),
});
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.error).toBe("Missing saleor-event header");
});
it("should return 200 for unsupported events (graceful skip)", async () => {
const request = new NextRequest("http://localhost:3000/api/webhooks/saleor", {
method: "POST",
headers: {
"saleor-event": "UNSUPPORTED_EVENT",
"saleor-domain": "api.manoonoils.com",
},
body: JSON.stringify([mockOrderPayload]),
});
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.message).toBe("Event not supported");
});
it("should handle server errors gracefully", async () => {
// Simulate service throwing error
vi.mocked(orderNotificationService.sendOrderConfirmationToAdmin).mockRejectedValueOnce(
new Error("Email service down")
);
const request = new NextRequest("http://localhost:3000/api/webhooks/saleor", {
method: "POST",
headers: {
"saleor-event": "ORDER_CREATED",
"saleor-domain": "api.manoonoils.com",
},
body: JSON.stringify([mockOrderPayload]),
});
const response = await POST(request);
expect(response.status).toBe(500);
});
});
describe("Currency Handling", () => {
it("should preserve RSD currency from Saleor payload", async () => {
const rsdOrder = {
...mockOrderPayload,
total_gross_amount: "5479",
channel: { currency_code: "RSD" },
};
const request = new NextRequest("http://localhost:3000/api/webhooks/saleor", {
method: "POST",
headers: {
"saleor-event": "ORDER_CONFIRMED",
"saleor-domain": "api.manoonoils.com",
},
body: JSON.stringify([rsdOrder]),
});
await POST(request);
// Verify the order passed to analytics has correct currency
expect(analyticsService.trackRevenue).toHaveBeenCalledWith(
expect.objectContaining({
amount: 5479,
currency: "RSD",
})
);
});
});
});