// src/tests/utils/testHelpers.ts // ============================================================================ // TEST HELPER UTILITIES // ============================================================================ // Provides type-safe utilities for API response assertions and mock casting. // See ADR-060 for the rationale and patterns documented here. // ============================================================================ import * as apiClient from '../../services/apiClient'; import { getPool } from '../../services/db/connection.db'; import type { UserProfile } from '../../types'; import type { ApiSuccessResponse, ApiErrorResponse } from '../../types/api'; import type { Mock } from 'vitest'; import supertest from 'supertest'; // Re-export createMockLogger for convenience export { createMockLogger, mockLogger } from './mockLogger'; // ============================================================================ // TYPE NARROWING UTILITIES // ============================================================================ /** * Type guard to narrow supertest response body to ApiSuccessResponse. * Use when accessing .data property on API responses in tests. * * This function asserts that the response body represents a successful API * response and returns it with the correct type. If the response is not * a success response, it throws an error with the actual response body * for debugging. * * @template T - The expected type of the data property * @param body - The response body from supertest (typically `response.body`) * @returns The response body typed as ApiSuccessResponse * @throws Error if the response is not a success response * * @example * // Basic usage with a single entity * const response = await request.get('/api/v1/users/1'); * const body = asSuccessResponse(response.body); * expect(body.data.id).toBe(1); // TypeScript knows body.data exists and is User * * @example * // Usage with an array response * const response = await request.get('/api/v1/flyers'); * const body = asSuccessResponse(response.body); * expect(body.data).toHaveLength(3); * expect(body.data[0].flyer_id).toBeDefined(); * * @example * // Usage with pagination metadata * const response = await request.get('/api/v1/flyers?page=1&limit=10'); * const body = asSuccessResponse(response.body); * expect(body.meta?.pagination?.page).toBe(1); */ export function asSuccessResponse(body: unknown): ApiSuccessResponse { const parsed = body as ApiSuccessResponse | ApiErrorResponse; if (parsed.success !== true) { throw new Error(`Expected success response, got: ${JSON.stringify(parsed, null, 2)}`); } return parsed; } /** * Type guard to narrow supertest response body to ApiErrorResponse. * Use when testing error scenarios and asserting on error properties. * * This function asserts that the response body represents an error API * response and returns it with the correct type. If the response is not * an error response, it throws an error for debugging. * * @param body - The response body from supertest (typically `response.body`) * @returns The response body typed as ApiErrorResponse * @throws Error if the response is not an error response * * @example * // Validation error assertion * const response = await request.post('/api/v1/users').send({ email: 'invalid' }); * expect(response.status).toBe(400); * const body = asErrorResponse(response.body); * expect(body.error.code).toBe('VALIDATION_ERROR'); * expect(body.error.message).toContain('Invalid'); * * @example * // Not found error assertion * const response = await request.get('/api/v1/users/nonexistent-id'); * expect(response.status).toBe(404); * const body = asErrorResponse(response.body); * expect(body.error.code).toBe('NOT_FOUND'); * * @example * // Error with details * const response = await request.post('/api/v1/auth/register').send({}); * const body = asErrorResponse(response.body); * expect(body.error.details).toBeDefined(); */ export function asErrorResponse(body: unknown): ApiErrorResponse { const parsed = body as ApiSuccessResponse | ApiErrorResponse; if (parsed.success !== false) { throw new Error(`Expected error response, got: ${JSON.stringify(parsed, null, 2)}`); } return parsed; } // ============================================================================ // MOCK CASTING UTILITIES // ============================================================================ /** * Cast Vitest mock to a specific function type. * Use when passing mocked functions to code expecting exact signatures, * or when accessing mock-specific methods like .mockResolvedValue(). * * This utility safely casts a vi.fn() mock to the expected function type, * avoiding TypeScript errors when mocks are used in place of real functions. * * @template T - The function type to cast to (use specific function signatures) * @param mock - A Vitest mock function (typically from vi.fn()) * @returns The mock cast to the specified function type * * @example * // Casting a simple mock * const mockFn = vi.fn(); * const typedMock = asMock<(id: string) => Promise>(mockFn); * typedMock.mockResolvedValue(mockUser); * * @example * // Using with service method types * vi.mock('@/services/userService'); * const mockCreate = vi.fn(); * someService.register(asMock(mockCreate)); * * @example * // Casting to access mock methods * const unknownMock = someModule.someMethod; * const typedMock = asMock(unknownMock); * expect(typedMock).toHaveBeenCalledWith('expected-arg'); */ export function asMock unknown>(mock: Mock): T { return mock as unknown as T; } // ============================================================================ // TEST CONSTANTS // ============================================================================ export const TEST_PASSWORD = 'a-much-stronger-password-for-testing-!@#$'; export const TEST_EXAMPLE_DOMAIN = 'https://example.com'; export const getTestBaseUrl = (): string => { const url = process.env.FRONTEND_URL || `https://example.com`; return url.endsWith('/') ? url.slice(0, -1) : url; }; /** * Get the flyer base URL for test data based on environment. * Uses FLYER_BASE_URL if set, otherwise detects environment: * - Dev container: https://localhost (NOT 127.0.0.1 - avoids SSL mixed-origin issues) * - Test: https://flyer-crawler-test.projectium.com * - Production: https://flyer-crawler.projectium.com * - Default: https://example.com (for unit tests) */ export const getFlyerBaseUrl = (): string => { if (process.env.FLYER_BASE_URL) { return process.env.FLYER_BASE_URL; } // Check if we're in dev container (DB_HOST=postgres is typical indicator) // Use 'localhost' instead of '127.0.0.1' to match the hostname users access // This avoids SSL certificate mixed-origin issues in browsers if (process.env.DB_HOST === 'postgres' || process.env.DB_HOST === '127.0.0.1') { return 'https://localhost'; } if (process.env.NODE_ENV === 'production') { return 'https://flyer-crawler.projectium.com'; } if (process.env.NODE_ENV === 'test') { return 'https://flyer-crawler-test.projectium.com'; } // Default for unit tests return 'https://example.com'; }; interface CreateUserOptions { email?: string; password?: string; fullName?: string; role?: 'admin' | 'user'; // Use ReturnType to match the actual return type of supertest(app) to avoid type mismatches (e.g. TestAgent vs SuperTest) request?: ReturnType; } interface CreateUserResult { user: UserProfile; token: string; } /** * A helper function for integration tests to create a new user and log them in. * This provides an authenticated context for testing protected API endpoints. * * @param options Optional parameters to customize the user. * @returns A promise that resolves to an object containing the user and auth token. */ export const createAndLoginUser = async ( options: CreateUserOptions = {}, ): Promise => { const email = options.email || `test-user-${Date.now()}@example.com`; const password = options.password || TEST_PASSWORD; const fullName = options.fullName || 'Test User'; if (options.request) { // Use supertest for integration tests (hits the app instance directly) const registerRes = await options.request .post('/api/v1/auth/register') .send({ email, password, full_name: fullName }); if (registerRes.status !== 201 && registerRes.status !== 200) { throw new Error( `Failed to register user via supertest: ${registerRes.status} ${JSON.stringify(registerRes.body)}`, ); } if (options.role === 'admin') { await getPool().query( `UPDATE public.profiles SET role = 'admin' FROM public.users WHERE public.profiles.user_id = public.users.user_id AND public.users.email = $1`, [email], ); } const loginRes = await options.request .post('/api/v1/auth/login') .send({ email, password, rememberMe: false }); if (loginRes.status !== 200) { throw new Error( `Failed to login user via supertest: ${loginRes.status} ${JSON.stringify(loginRes.body)}`, ); } const { userprofile, token } = loginRes.body.data; return { user: userprofile, token }; } else { // Use apiClient for E2E tests (hits the external URL via fetch) await apiClient.registerUser(email, password, fullName); if (options.role === 'admin') { await getPool().query( `UPDATE public.profiles SET role = 'admin' FROM public.users WHERE public.profiles.user_id = public.users.user_id AND public.users.email = $1`, [email], ); } const loginResponse = await apiClient.loginUser(email, password, false); if (!loginResponse.ok) { throw new Error(`Failed to login user via apiClient: ${loginResponse.status}`); } const responseData = await loginResponse.json(); const { userprofile, token } = responseData.data; return { user: userprofile, token }; } };