Compare commits
6 Commits
feature/sa
...
test/suite
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85e41bfcc4 | ||
|
|
84b85f5291 | ||
|
|
c98677405a | ||
|
|
4d428b3ff0 | ||
|
|
9c2e4e1383 | ||
|
|
d0e3ee3201 |
2575
package-lock.json
generated
2575
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
19
package.json
19
package.json
@@ -6,7 +6,13 @@
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
"lint": "eslint",
|
||||
"test": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:coverage": "vitest --coverage",
|
||||
"test:run": "vitest run",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui"
|
||||
},
|
||||
"dependencies": {
|
||||
"@apollo/client": "^4.1.6",
|
||||
@@ -26,13 +32,22 @@
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@vitest/coverage-v8": "^4.1.1",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"jsdom": "^29.0.1",
|
||||
"msw": "^2.12.14",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
"typescript": "^5",
|
||||
"vitest": "^4.1.1"
|
||||
}
|
||||
}
|
||||
|
||||
141
src/__tests__/README.md
Normal file
141
src/__tests__/README.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# Manoon Storefront Test Suite
|
||||
|
||||
Comprehensive test suite for the ManoonOils storefront with focus on webhooks, commerce operations, and critical paths.
|
||||
|
||||
## 🎯 Coverage Goals
|
||||
|
||||
- **Critical Paths**: 80%+ coverage
|
||||
- **Webhook Handlers**: 100% coverage
|
||||
- **Email Services**: 90%+ coverage
|
||||
- **Analytics**: 80%+ coverage
|
||||
|
||||
## 🧪 Test Structure
|
||||
|
||||
```
|
||||
src/__tests__/
|
||||
├── unit/
|
||||
│ ├── services/ # Business logic tests
|
||||
│ │ ├── OrderNotificationService.test.ts
|
||||
│ │ └── AnalyticsService.test.ts
|
||||
│ ├── stores/ # State management tests
|
||||
│ │ └── saleorCheckoutStore.test.ts
|
||||
│ └── utils/ # Utility function tests
|
||||
│ └── formatPrice.test.ts
|
||||
├── integration/
|
||||
│ ├── api/
|
||||
│ │ └── webhooks/
|
||||
│ │ └── saleor.test.ts # Webhook handler tests
|
||||
│ └── emails/
|
||||
│ ├── OrderConfirmation.test.tsx
|
||||
│ └── OrderShipped.test.tsx
|
||||
└── fixtures/ # Test data
|
||||
└── orders.ts
|
||||
```
|
||||
|
||||
## 🚀 Running Tests
|
||||
|
||||
### Unit & Integration Tests (Vitest)
|
||||
|
||||
```bash
|
||||
# Run tests in watch mode
|
||||
npm test
|
||||
|
||||
# Run tests once
|
||||
npm run test:run
|
||||
|
||||
# Run with coverage report
|
||||
npm run test:coverage
|
||||
|
||||
# Run with UI
|
||||
npm run test:ui
|
||||
```
|
||||
|
||||
### E2E Tests (Playwright)
|
||||
|
||||
```bash
|
||||
# Run all E2E tests
|
||||
npm run test:e2e
|
||||
|
||||
# Run with UI mode
|
||||
npm run test:e2e:ui
|
||||
|
||||
# Run specific test
|
||||
npx playwright test tests/critical-paths/checkout-flow.spec.ts
|
||||
```
|
||||
|
||||
## 📊 Test Categories
|
||||
|
||||
### 🔥 Critical Tests (Must Pass)
|
||||
|
||||
1. **Webhook Handler Tests**
|
||||
- ORDER_CONFIRMED: Sends emails + analytics
|
||||
- ORDER_CREATED: No duplicate emails/analytics
|
||||
- ORDER_FULFILLED: Tracking info included
|
||||
- ORDER_CANCELLED: Cancellation reason included
|
||||
- ORDER_FULLY_PAID: Payment confirmation
|
||||
|
||||
2. **Email Service Tests**
|
||||
- Correct translations (SR, EN, DE, FR)
|
||||
- Price formatting (no /100 bug)
|
||||
- Admin vs Customer templates
|
||||
- Address formatting
|
||||
|
||||
3. **Analytics Tests**
|
||||
- Revenue tracked once per order
|
||||
- Correct currency (RSD not USD)
|
||||
- Error handling (doesn't break order flow)
|
||||
|
||||
### 🔧 Integration Tests
|
||||
|
||||
- Full checkout flow
|
||||
- Cart operations
|
||||
- Email template rendering
|
||||
- API error handling
|
||||
|
||||
## 🎭 Mocking Strategy
|
||||
|
||||
- **Resend**: Mocked (no actual emails sent)
|
||||
- **OpenPanel**: Mocked (no actual tracking in tests)
|
||||
- **Saleor API**: Can use real instance for integration tests (read-only)
|
||||
|
||||
## 📈 Coverage Reports
|
||||
|
||||
Coverage reports are generated in multiple formats:
|
||||
- Console output (text)
|
||||
- `coverage/coverage-final.json` (JSON)
|
||||
- `coverage/index.html` (HTML report)
|
||||
|
||||
Open `coverage/index.html` in browser for detailed view.
|
||||
|
||||
## 🔍 Debugging Tests
|
||||
|
||||
```bash
|
||||
# Debug specific test
|
||||
npm test -- --reporter=verbose src/__tests__/unit/services/AnalyticsService.test.ts
|
||||
|
||||
# Debug with logs
|
||||
DEBUG=true npm test
|
||||
```
|
||||
|
||||
## 📝 Adding New Tests
|
||||
|
||||
1. Create test file: `src/__tests__/unit|integration/path/to/file.test.ts`
|
||||
2. Import from `@/` alias (configured in vitest.config.ts)
|
||||
3. Use fixtures from `src/__tests__/fixtures/`
|
||||
4. Mock external services
|
||||
5. Run tests to verify
|
||||
|
||||
## 🚧 Current Limitations
|
||||
|
||||
- No CI/CD integration yet (informational only)
|
||||
- E2E tests need Playwright browser installation
|
||||
- Some tests use mocked data instead of real Saleor API
|
||||
|
||||
## ✅ Test Checklist
|
||||
|
||||
Before deploying, ensure:
|
||||
- [ ] All webhook tests pass
|
||||
- [ ] Email service tests pass
|
||||
- [ ] Analytics tests pass
|
||||
- [ ] Coverage >= 80% for critical paths
|
||||
- [ ] No console errors in tests
|
||||
112
src/__tests__/fixtures/orders.ts
Normal file
112
src/__tests__/fixtures/orders.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
// Test fixtures for orders
|
||||
export const mockOrderPayload = {
|
||||
id: "T3JkZXI6MTIzNDU2Nzg=",
|
||||
number: 1524,
|
||||
user_email: "test@hytham.me",
|
||||
first_name: "Test",
|
||||
last_name: "Customer",
|
||||
billing_address: {
|
||||
first_name: "Test",
|
||||
last_name: "Customer",
|
||||
street_address_1: "123 Test Street",
|
||||
street_address_2: "",
|
||||
city: "Belgrade",
|
||||
postal_code: "11000",
|
||||
country: "RS",
|
||||
phone: "+38160123456",
|
||||
},
|
||||
shipping_address: {
|
||||
first_name: "Test",
|
||||
last_name: "Customer",
|
||||
street_address_1: "123 Test Street",
|
||||
street_address_2: "",
|
||||
city: "Belgrade",
|
||||
postal_code: "11000",
|
||||
country: "RS",
|
||||
phone: "+38160123456",
|
||||
},
|
||||
lines: [
|
||||
{
|
||||
id: "T3JkZXJMaW5lOjE=",
|
||||
product_name: "Manoon Anti-age Serum",
|
||||
variant_name: "50ml",
|
||||
quantity: 2,
|
||||
total_price_gross_amount: "10000",
|
||||
currency: "RSD",
|
||||
},
|
||||
],
|
||||
total_gross_amount: "10000",
|
||||
shipping_price_gross_amount: "480",
|
||||
channel: {
|
||||
currency_code: "RSD",
|
||||
},
|
||||
language_code: "EN",
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
export const mockOrderConverted = {
|
||||
id: "T3JkZXI6MTIzNDU2Nzg=",
|
||||
number: "1524",
|
||||
userEmail: "test@hytham.me",
|
||||
user: {
|
||||
firstName: "Test",
|
||||
lastName: "Customer",
|
||||
},
|
||||
billingAddress: {
|
||||
firstName: "Test",
|
||||
lastName: "Customer",
|
||||
streetAddress1: "123 Test Street",
|
||||
streetAddress2: "",
|
||||
city: "Belgrade",
|
||||
postalCode: "11000",
|
||||
country: "RS",
|
||||
phone: "+38160123456",
|
||||
},
|
||||
shippingAddress: {
|
||||
firstName: "Test",
|
||||
lastName: "Customer",
|
||||
streetAddress1: "123 Test Street",
|
||||
streetAddress2: "",
|
||||
city: "Belgrade",
|
||||
postalCode: "11000",
|
||||
country: "RS",
|
||||
phone: "+38160123456",
|
||||
},
|
||||
lines: [
|
||||
{
|
||||
id: "T3JkZXJMaW5lOjE=",
|
||||
productName: "Manoon Anti-age Serum",
|
||||
variantName: "50ml",
|
||||
quantity: 2,
|
||||
totalPrice: {
|
||||
gross: {
|
||||
amount: 10000,
|
||||
currency: "RSD",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
total: {
|
||||
gross: {
|
||||
amount: 10000,
|
||||
currency: "RSD",
|
||||
},
|
||||
},
|
||||
languageCode: "EN",
|
||||
metadata: [],
|
||||
};
|
||||
|
||||
export const mockOrderWithTracking = {
|
||||
...mockOrderPayload,
|
||||
metadata: {
|
||||
trackingNumber: "TRK123456789",
|
||||
trackingUrl: "https://tracking.example.com/TRK123456789",
|
||||
},
|
||||
};
|
||||
|
||||
export const mockOrderCancelled = {
|
||||
...mockOrderPayload,
|
||||
metadata: {
|
||||
cancellationReason: "Customer requested cancellation",
|
||||
},
|
||||
};
|
||||
280
src/__tests__/integration/api/webhooks/saleor.test.ts
Normal file
280
src/__tests__/integration/api/webhooks/saleor.test.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
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",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
35
src/__tests__/setup.ts
Normal file
35
src/__tests__/setup.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import "@testing-library/jest-dom";
|
||||
import { vi } from "vitest";
|
||||
|
||||
// Mock environment variables
|
||||
process.env.NEXT_PUBLIC_SALEOR_API_URL = "https://api.manoonoils.com/graphql/";
|
||||
process.env.NEXT_PUBLIC_SITE_URL = "https://dev.manoonoils.com";
|
||||
process.env.DASHBOARD_URL = "https://dashboard.manoonoils.com";
|
||||
process.env.RESEND_API_KEY = "test-api-key";
|
||||
process.env.NEXT_PUBLIC_OPENPANEL_CLIENT_ID = "test-client-id";
|
||||
process.env.OPENPANEL_CLIENT_SECRET = "test-client-secret";
|
||||
process.env.OPENPANEL_API_URL = "https://op.nodecrew.me/api";
|
||||
|
||||
// Mock Resend
|
||||
vi.mock("resend", () => ({
|
||||
Resend: vi.fn().mockImplementation(() => ({
|
||||
emails: {
|
||||
send: vi.fn().mockResolvedValue({ id: "test-email-id" }),
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock OpenPanel
|
||||
vi.mock("@openpanel/nextjs", () => ({
|
||||
OpenPanel: vi.fn().mockImplementation(() => ({
|
||||
track: vi.fn().mockResolvedValue(undefined),
|
||||
revenue: vi.fn().mockResolvedValue(undefined),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Global test utilities
|
||||
global.ResizeObserver = vi.fn().mockImplementation(() => ({
|
||||
observe: vi.fn(),
|
||||
unobserve: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
}));
|
||||
233
src/__tests__/unit/services/AnalyticsService.test.ts
Normal file
233
src/__tests__/unit/services/AnalyticsService.test.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// Create mock functions using vi.hoisted so they're available during mock setup
|
||||
const { mockTrack, mockRevenue } = vi.hoisted(() => ({
|
||||
mockTrack: vi.fn().mockResolvedValue(undefined),
|
||||
mockRevenue: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// Mock OpenPanel using factory function
|
||||
vi.mock("@openpanel/nextjs", () => {
|
||||
return {
|
||||
OpenPanel: class MockOpenPanel {
|
||||
track = mockTrack;
|
||||
revenue = mockRevenue;
|
||||
constructor() {}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Import after mock is set up
|
||||
import { AnalyticsService } from "@/lib/services/AnalyticsService";
|
||||
|
||||
describe("AnalyticsService", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("trackOrderReceived", () => {
|
||||
it("should track order with all details", async () => {
|
||||
await new AnalyticsService().trackOrderReceived({
|
||||
orderId: "order-123",
|
||||
orderNumber: "1524",
|
||||
total: 5479,
|
||||
currency: "RSD",
|
||||
itemCount: 3,
|
||||
customerEmail: "test@example.com",
|
||||
eventType: "ORDER_CONFIRMED",
|
||||
});
|
||||
|
||||
expect(mockTrack).toHaveBeenCalledWith("order_received", {
|
||||
order_id: "order-123",
|
||||
order_number: "1524",
|
||||
total: 5479,
|
||||
currency: "RSD",
|
||||
item_count: 3,
|
||||
customer_email: "test@example.com",
|
||||
event_type: "ORDER_CONFIRMED",
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle large order values", async () => {
|
||||
await new AnalyticsService().trackOrderReceived({
|
||||
orderId: "order-456",
|
||||
orderNumber: "2000",
|
||||
total: 500000, // Large amount
|
||||
currency: "RSD",
|
||||
itemCount: 100,
|
||||
customerEmail: "bulk@example.com",
|
||||
eventType: "ORDER_CONFIRMED",
|
||||
});
|
||||
|
||||
expect(mockTrack).toHaveBeenCalledWith(
|
||||
"order_received",
|
||||
expect.objectContaining({
|
||||
total: 500000,
|
||||
item_count: 100,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should not throw if tracking fails", async () => {
|
||||
mockTrack.mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
await expect(
|
||||
new AnalyticsService().trackOrderReceived({
|
||||
orderId: "order-123",
|
||||
orderNumber: "1524",
|
||||
total: 1000,
|
||||
currency: "RSD",
|
||||
itemCount: 1,
|
||||
customerEmail: "test@example.com",
|
||||
eventType: "ORDER_CONFIRMED",
|
||||
})
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("trackRevenue", () => {
|
||||
it("should track revenue with correct currency", async () => {
|
||||
await new AnalyticsService().trackRevenue({
|
||||
amount: 5479,
|
||||
currency: "RSD",
|
||||
orderId: "order-123",
|
||||
orderNumber: "1524",
|
||||
});
|
||||
|
||||
expect(mockRevenue).toHaveBeenCalledWith(5479, {
|
||||
currency: "RSD",
|
||||
order_id: "order-123",
|
||||
order_number: "1524",
|
||||
});
|
||||
});
|
||||
|
||||
it("should track revenue with different currencies", async () => {
|
||||
// Test EUR
|
||||
await new AnalyticsService().trackRevenue({
|
||||
amount: 100,
|
||||
currency: "EUR",
|
||||
orderId: "order-1",
|
||||
orderNumber: "1000",
|
||||
});
|
||||
|
||||
expect(mockRevenue).toHaveBeenCalledWith(100, {
|
||||
currency: "EUR",
|
||||
order_id: "order-1",
|
||||
order_number: "1000",
|
||||
});
|
||||
|
||||
// Test USD
|
||||
await new AnalyticsService().trackRevenue({
|
||||
amount: 150,
|
||||
currency: "USD",
|
||||
orderId: "order-2",
|
||||
orderNumber: "1001",
|
||||
});
|
||||
|
||||
expect(mockRevenue).toHaveBeenCalledWith(150, {
|
||||
currency: "USD",
|
||||
order_id: "order-2",
|
||||
order_number: "1001",
|
||||
});
|
||||
});
|
||||
|
||||
it("should log tracking for debugging", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
await new AnalyticsService().trackRevenue({
|
||||
amount: 5479,
|
||||
currency: "RSD",
|
||||
orderId: "order-123",
|
||||
orderNumber: "1524",
|
||||
});
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
"Tracking revenue: 5479 RSD for order 1524"
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should not throw if revenue tracking fails", async () => {
|
||||
mockRevenue.mockRejectedValueOnce(new Error("API error"));
|
||||
|
||||
await expect(
|
||||
new AnalyticsService().trackRevenue({
|
||||
amount: 1000,
|
||||
currency: "RSD",
|
||||
orderId: "order-123",
|
||||
orderNumber: "1524",
|
||||
})
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it("should handle zero amount orders", async () => {
|
||||
await new AnalyticsService().trackRevenue({
|
||||
amount: 0,
|
||||
currency: "RSD",
|
||||
orderId: "order-000",
|
||||
orderNumber: "0000",
|
||||
});
|
||||
|
||||
expect(mockRevenue).toHaveBeenCalledWith(0, {
|
||||
currency: "RSD",
|
||||
order_id: "order-000",
|
||||
order_number: "0000",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("track", () => {
|
||||
it("should track custom events", async () => {
|
||||
await new AnalyticsService().track("custom_event", {
|
||||
property1: "value1",
|
||||
property2: 123,
|
||||
});
|
||||
|
||||
expect(mockTrack).toHaveBeenCalledWith("custom_event", {
|
||||
property1: "value1",
|
||||
property2: 123,
|
||||
});
|
||||
});
|
||||
|
||||
it("should not throw on tracking errors", async () => {
|
||||
mockTrack.mockRejectedValueOnce(new Error("Tracking failed"));
|
||||
|
||||
await expect(
|
||||
new AnalyticsService().track("test_event", { test: true })
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Singleton pattern", () => {
|
||||
it("should return the same instance", async () => {
|
||||
// Import fresh to test singleton using dynamic import
|
||||
const { analyticsService: service1 } = await import("@/lib/services/AnalyticsService");
|
||||
const { analyticsService: service2 } = await import("@/lib/services/AnalyticsService");
|
||||
|
||||
expect(service1).toBe(service2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error handling", () => {
|
||||
it("should log errors but not throw", async () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
mockTrack.mockRejectedValueOnce(new Error("Test error"));
|
||||
|
||||
await new AnalyticsService().trackOrderReceived({
|
||||
orderId: "order-123",
|
||||
orderNumber: "1524",
|
||||
total: 1000,
|
||||
currency: "RSD",
|
||||
itemCount: 1,
|
||||
customerEmail: "test@example.com",
|
||||
eventType: "ORDER_CONFIRMED",
|
||||
});
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalled();
|
||||
expect(consoleErrorSpy.mock.calls[0][0]).toContain("Failed to track order received");
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
263
src/__tests__/unit/services/OrderNotificationService.test.ts
Normal file
263
src/__tests__/unit/services/OrderNotificationService.test.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { orderNotificationService } from "@/lib/services/OrderNotificationService";
|
||||
import { sendEmailToCustomer, sendEmailToAdmin } from "@/lib/resend";
|
||||
import { mockOrderConverted } from "../../fixtures/orders";
|
||||
|
||||
// Mock the resend module
|
||||
vi.mock("@/lib/resend", () => ({
|
||||
sendEmailToCustomer: vi.fn().mockResolvedValue({ id: "test-email-id" }),
|
||||
sendEmailToAdmin: vi.fn().mockResolvedValue({ id: "test-email-id" }),
|
||||
ADMIN_EMAILS: ["me@hytham.me", "tamara@hytham.me"],
|
||||
}));
|
||||
|
||||
describe("OrderNotificationService", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("sendOrderConfirmation", () => {
|
||||
it("should send customer order confirmation in correct language (EN)", async () => {
|
||||
const order = { ...mockOrderConverted, languageCode: "EN" };
|
||||
|
||||
await orderNotificationService.sendOrderConfirmation(order);
|
||||
|
||||
expect(sendEmailToCustomer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: "test@hytham.me",
|
||||
subject: "Order Confirmation #1524",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should send customer order confirmation in Serbian (SR)", async () => {
|
||||
const order = { ...mockOrderConverted, languageCode: "SR" };
|
||||
|
||||
await orderNotificationService.sendOrderConfirmation(order);
|
||||
|
||||
expect(sendEmailToCustomer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: "test@hytham.me",
|
||||
subject: "Potvrda narudžbine #1524",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should send customer order confirmation in German (DE)", async () => {
|
||||
const order = { ...mockOrderConverted, languageCode: "DE" };
|
||||
|
||||
await orderNotificationService.sendOrderConfirmation(order);
|
||||
|
||||
expect(sendEmailToCustomer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: "test@hytham.me",
|
||||
subject: "Bestellbestätigung #1524",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should send customer order confirmation in French (FR)", async () => {
|
||||
const order = { ...mockOrderConverted, languageCode: "FR" };
|
||||
|
||||
await orderNotificationService.sendOrderConfirmation(order);
|
||||
|
||||
expect(sendEmailToCustomer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: "test@hytham.me",
|
||||
subject: "Confirmation de commande #1524",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should format price correctly", async () => {
|
||||
const order = {
|
||||
...mockOrderConverted,
|
||||
total: {
|
||||
gross: {
|
||||
amount: 5479,
|
||||
currency: "RSD",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await orderNotificationService.sendOrderConfirmation(order);
|
||||
|
||||
expect(sendEmailToCustomer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
subject: "Order Confirmation #1524",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle missing variant name gracefully", async () => {
|
||||
const order = {
|
||||
...mockOrderConverted,
|
||||
lines: [
|
||||
{
|
||||
...mockOrderConverted.lines[0],
|
||||
variantName: undefined,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await orderNotificationService.sendOrderConfirmation(order);
|
||||
|
||||
expect(sendEmailToCustomer).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should include variant name when present", async () => {
|
||||
await orderNotificationService.sendOrderConfirmation(mockOrderConverted);
|
||||
|
||||
expect(sendEmailToCustomer).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendOrderConfirmationToAdmin", () => {
|
||||
it("should send admin notification with order details", async () => {
|
||||
await orderNotificationService.sendOrderConfirmationToAdmin(mockOrderConverted);
|
||||
|
||||
expect(sendEmailToAdmin).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
subject: expect.stringContaining("🎉 New Order #1524"),
|
||||
eventType: "ORDER_CONFIRMED",
|
||||
orderId: "T3JkZXI6MTIzNDU2Nzg=",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should always use English for admin emails", async () => {
|
||||
const order = { ...mockOrderConverted, languageCode: "SR" };
|
||||
|
||||
await orderNotificationService.sendOrderConfirmationToAdmin(order);
|
||||
|
||||
expect(sendEmailToAdmin).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
eventType: "ORDER_CONFIRMED",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should include all order details in admin email", async () => {
|
||||
await orderNotificationService.sendOrderConfirmationToAdmin(mockOrderConverted);
|
||||
|
||||
expect(sendEmailToAdmin).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
subject: expect.stringContaining("🎉 New Order"),
|
||||
eventType: "ORDER_CONFIRMED",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendOrderShipped", () => {
|
||||
it("should send shipping confirmation with tracking", async () => {
|
||||
await orderNotificationService.sendOrderShipped(
|
||||
mockOrderConverted,
|
||||
"TRK123",
|
||||
"https://track.com/TRK123"
|
||||
);
|
||||
|
||||
expect(sendEmailToCustomer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: "test@hytham.me",
|
||||
subject: "Your Order #1524 Has Shipped!",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle missing tracking info", async () => {
|
||||
await orderNotificationService.sendOrderShipped(mockOrderConverted);
|
||||
|
||||
expect(sendEmailToCustomer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
subject: "Your Order #1524 Has Shipped!",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendOrderCancelled", () => {
|
||||
it("should send cancellation email with reason", async () => {
|
||||
await orderNotificationService.sendOrderCancelled(
|
||||
mockOrderConverted,
|
||||
"Out of stock"
|
||||
);
|
||||
|
||||
expect(sendEmailToCustomer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: "test@hytham.me",
|
||||
subject: "Your Order #1524 Has Been Cancelled",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendOrderPaid", () => {
|
||||
it("should send payment confirmation", async () => {
|
||||
await orderNotificationService.sendOrderPaid(mockOrderConverted);
|
||||
|
||||
expect(sendEmailToCustomer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: "test@hytham.me",
|
||||
subject: "Payment Received for Order #1524!",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatPrice", () => {
|
||||
it("should format prices correctly for RSD", () => {
|
||||
// This is tested indirectly through the email calls above
|
||||
// The formatPrice function is in utils.ts
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("should handle orders with user name", async () => {
|
||||
const order = {
|
||||
...mockOrderConverted,
|
||||
user: { firstName: "John", lastName: "Doe" },
|
||||
};
|
||||
|
||||
await orderNotificationService.sendOrderConfirmation(order);
|
||||
|
||||
expect(sendEmailToCustomer).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle orders without user object", async () => {
|
||||
const order = {
|
||||
...mockOrderConverted,
|
||||
user: undefined,
|
||||
};
|
||||
|
||||
await orderNotificationService.sendOrderConfirmation(order);
|
||||
|
||||
expect(sendEmailToCustomer).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle orders with incomplete address", async () => {
|
||||
const order = {
|
||||
...mockOrderConverted,
|
||||
shippingAddress: {
|
||||
firstName: "Test",
|
||||
lastName: "Customer",
|
||||
city: "Belgrade",
|
||||
},
|
||||
};
|
||||
|
||||
await orderNotificationService.sendOrderConfirmation(order);
|
||||
|
||||
expect(sendEmailToCustomer).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle orders with missing shipping address", async () => {
|
||||
const order = {
|
||||
...mockOrderConverted,
|
||||
shippingAddress: undefined,
|
||||
};
|
||||
|
||||
await orderNotificationService.sendOrderConfirmation(order);
|
||||
|
||||
expect(sendEmailToCustomer).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
42
src/__tests__/unit/utils/formatPrice.test.ts
Normal file
42
src/__tests__/unit/utils/formatPrice.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { formatPrice } from "@/app/api/webhooks/saleor/utils";
|
||||
|
||||
describe("formatPrice", () => {
|
||||
it("should format RSD currency correctly", () => {
|
||||
const result = formatPrice(5479, "RSD");
|
||||
// Note: sr-RS locale uses non-breaking space between number and currency
|
||||
expect(result).toMatch(/5\.479,00\sRSD/);
|
||||
});
|
||||
|
||||
it("should format small amounts correctly", () => {
|
||||
const result = formatPrice(50, "RSD");
|
||||
expect(result).toMatch(/50,00\sRSD/);
|
||||
});
|
||||
|
||||
it("should format large amounts correctly", () => {
|
||||
const result = formatPrice(100000, "RSD");
|
||||
expect(result).toMatch(/100\.000,00\sRSD/);
|
||||
});
|
||||
|
||||
it("should format EUR currency correctly", () => {
|
||||
const result = formatPrice(100, "EUR");
|
||||
// sr-RS locale uses € symbol for EUR
|
||||
expect(result).toMatch(/100,00\s€/);
|
||||
});
|
||||
|
||||
it("should format USD currency correctly", () => {
|
||||
const result = formatPrice(150, "USD");
|
||||
// sr-RS locale uses US$ symbol for USD
|
||||
expect(result).toMatch(/150,00\sUS\$/);
|
||||
});
|
||||
|
||||
it("should handle decimal amounts", () => {
|
||||
const result = formatPrice(1000.5, "RSD");
|
||||
expect(result).toMatch(/1\.000,50\sRSD/);
|
||||
});
|
||||
|
||||
it("should handle zero", () => {
|
||||
const result = formatPrice(0, "RSD");
|
||||
expect(result).toMatch(/0,00\sRSD/);
|
||||
});
|
||||
});
|
||||
@@ -76,4 +76,5 @@ class AnalyticsService {
|
||||
}
|
||||
|
||||
export const analyticsService = AnalyticsService.getInstance();
|
||||
export { AnalyticsService };
|
||||
export default analyticsService;
|
||||
|
||||
36
vitest.config.ts
Normal file
36
vitest.config.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import path from "path";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
globals: true,
|
||||
setupFiles: ["./src/__tests__/setup.ts"],
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
reporter: ["text", "json", "html"],
|
||||
thresholds: {
|
||||
lines: 80,
|
||||
functions: 80,
|
||||
branches: 80,
|
||||
statements: 80,
|
||||
},
|
||||
exclude: [
|
||||
"node_modules/",
|
||||
"src/__tests__/",
|
||||
"**/*.d.ts",
|
||||
"**/*.config.*",
|
||||
"**/e2e/**",
|
||||
],
|
||||
},
|
||||
include: ["src/**/*.test.ts", "src/**/*.test.tsx"],
|
||||
exclude: ["node_modules", "dist", ".next", "e2e"],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user