mock-server.ts•8.51 kB
import nock from 'nock';
import { ProductResponse, SearchResponse } from '../../src/types.js';
import {
mockNutellaProductResponse,
mockHealthyYogurtProductResponse,
mockVeganProductResponse,
mockProductNotFoundResponse,
mockSearchResponse,
mockEmptySearchResponse
} from '../fixtures/products.js';
/**
* Mock server utilities for testing HTTP interactions
*/
export class MockOpenFoodFactsServer {
private scope: nock.Scope;
private baseUrl: string;
constructor(baseUrl = 'https://test.openfoodfacts.net') {
this.baseUrl = baseUrl;
this.scope = nock(baseUrl);
}
/**
* Mock a successful product lookup
*/
mockGetProduct(barcode: string, response: ProductResponse = mockNutellaProductResponse): this {
this.scope
.get(`/api/v2/product/${barcode}`)
.reply(200, response);
return this;
}
/**
* Mock a product not found
*/
mockGetProductNotFound(barcode: string): this {
this.scope
.get(`/api/v2/product/${barcode}`)
.reply(200, mockProductNotFoundResponse);
return this;
}
/**
* Mock a product lookup with error
*/
mockGetProductError(barcode: string, status = 500, message = 'Server Error'): this {
this.scope
.get(`/api/v2/product/${barcode}`)
.reply(status, message);
return this;
}
/**
* Mock a network error for product lookup
*/
mockGetProductNetworkError(barcode: string): this {
this.scope
.get(`/api/v2/product/${barcode}`)
.replyWithError('Network Error');
return this;
}
/**
* Mock a successful search
*/
mockSearch(params: Record<string, any> = {}, response: SearchResponse = mockSearchResponse): this {
let path = '/cgi/search.pl';
if (Object.keys(params).length > 0) {
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
searchParams.append(key, String(value));
});
path += `?${searchParams.toString()}`;
}
this.scope
.get(path)
.reply(200, response);
return this;
}
/**
* Mock search with any parameters
*/
mockSearchAny(response: SearchResponse = mockSearchResponse): this {
this.scope
.get('/cgi/search.pl')
.query(true) // Match any query parameters
.reply(200, response);
return this;
}
/**
* Mock an empty search result
*/
mockSearchEmpty(params: Record<string, any> = {}): this {
return this.mockSearch(params, mockEmptySearchResponse);
}
/**
* Mock a search error
*/
mockSearchError(status = 500, message = 'Server Error'): this {
this.scope
.get('/cgi/search.pl')
.query(true)
.reply(status, message);
return this;
}
/**
* Mock rate limit error
*/
mockRateLimit(endpoint: 'product' | 'search'): this {
const path = endpoint === 'product' ? '/api/v2/product' : '/cgi/search.pl';
this.scope
.get(new RegExp(path))
.reply(429, 'Too Many Requests');
return this;
}
/**
* Mock multiple products for comparison testing
*/
mockMultipleProducts(barcodes: string[], responses?: ProductResponse[]): this {
barcodes.forEach((barcode, index) => {
const response = responses?.[index] || this.getDefaultResponse(index);
this.mockGetProduct(barcode, response);
});
return this;
}
/**
* Mock search with pagination
*/
mockPaginatedSearch(
totalCount: number,
pageSize: number,
currentPage: number,
products: any[] = []
): this {
const pageCount = Math.ceil(totalCount / pageSize);
const response: SearchResponse = {
count: totalCount,
page: currentPage,
page_count: pageCount,
page_size: pageSize,
products,
};
this.scope
.get('/cgi/search.pl')
.query(query => query.page === String(currentPage))
.reply(200, response);
return this;
}
/**
* Mock delayed response for timeout testing
*/
mockDelayedResponse(barcode: string, delayMs: number, response: ProductResponse = mockNutellaProductResponse): this {
this.scope
.get(`/api/v2/product/${barcode}`)
.delay(delayMs)
.reply(200, response);
return this;
}
/**
* Mock intermittent failures
*/
mockIntermittentFailure(barcode: string, failCount: number): this {
// First few calls fail
for (let i = 0; i < failCount; i++) {
this.scope
.get(`/api/v2/product/${barcode}`)
.reply(500, 'Temporary Server Error');
}
// Then succeed
this.scope
.get(`/api/v2/product/${barcode}`)
.reply(200, mockNutellaProductResponse);
return this;
}
/**
* Verify that all mocked endpoints were called
*/
isDone(): boolean {
return this.scope.isDone();
}
/**
* Get pending mocks (not yet called)
*/
pendingMocks(): string[] {
return this.scope.pendingMocks();
}
/**
* Clean up all mocks
*/
cleanup(): void {
nock.cleanAll();
}
/**
* Get request history for debugging
*/
getRequestHistory(): any[] {
return (nock as any).recorder.records() || [];
}
/**
* Mock the entire OFF API server with common endpoints
*/
mockCompleteServer(): this {
// Mock popular products
this.mockGetProduct('3017620422003', mockNutellaProductResponse);
this.mockGetProduct('3229820787015', mockHealthyYogurtProductResponse);
this.mockGetProduct('8712566073219', mockVeganProductResponse);
// Mock search endpoints
this.mockSearchAny(mockSearchResponse);
// Mock 404s for unknown products
this.scope
.get(/\/api\/v2\/product\/0+/)
.reply(200, mockProductNotFoundResponse);
return this;
}
/**
* Mock server with realistic delays
*/
mockRealisticServer(): this {
// Product lookups - fast
this.scope
.get(/\/api\/v2\/product\/\d+/)
.delay(50)
.reply(200, mockNutellaProductResponse);
// Search - slower
this.scope
.get('/cgi/search.pl')
.query(true)
.delay(200)
.reply(200, mockSearchResponse);
return this;
}
/**
* Mock server that respects rate limits
*/
mockRateLimitedServer(requestsPerMinute: number): this {
let requestCount = 0;
const resetTime = Date.now() + 60000; // 1 minute from now
this.scope
.get(/\/api\/v2\/product\/\d+/)
.reply(() => {
if (Date.now() > resetTime) {
requestCount = 0; // Reset counter
}
requestCount++;
if (requestCount > requestsPerMinute) {
return [429, 'Rate limit exceeded'];
}
return [200, mockNutellaProductResponse];
})
.persist();
return this;
}
private getDefaultResponse(index: number): ProductResponse {
const responses = [
mockNutellaProductResponse,
mockHealthyYogurtProductResponse,
mockVeganProductResponse,
];
return responses[index % responses.length];
}
}
/**
* Helper function to create a mock server
*/
export function createMockServer(baseUrl?: string): MockOpenFoodFactsServer {
return new MockOpenFoodFactsServer(baseUrl);
}
/**
* Helper to mock a complete test scenario
*/
export function mockTestScenario(scenario: 'success' | 'errors' | 'mixed' | 'rate-limited'): MockOpenFoodFactsServer {
const server = createMockServer();
switch (scenario) {
case 'success':
return server.mockCompleteServer();
case 'errors':
server.mockGetProductError('3017620422003', 500);
server.mockSearchError(500);
return server;
case 'mixed':
server.mockGetProduct('3017620422003', mockNutellaProductResponse);
server.mockGetProductNotFound('0000000000000');
server.mockGetProductError('1111111111111', 500);
server.mockSearchAny(mockSearchResponse);
return server;
case 'rate-limited':
return server.mockRateLimitedServer(5);
default:
return server;
}
}
/**
* Utility to wait for all HTTP mocks to complete
*/
export async function waitForMocks(server: MockOpenFoodFactsServer, timeoutMs = 5000): Promise<void> {
const startTime = Date.now();
while (!server.isDone() && (Date.now() - startTime) < timeoutMs) {
await new Promise(resolve => setTimeout(resolve, 10));
}
if (!server.isDone()) {
throw new Error(`Mocks not completed within ${timeoutMs}ms. Pending: ${server.pendingMocks().join(', ')}`);
}
}