Files
flyer-crawler.projectium.com/src/tests/utils/testHelpers.ts
Torben Sorensen 174b637a0a
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 25m5s
even more typescript fixes
2026-02-17 17:20:54 -08:00

266 lines
10 KiB
TypeScript

// 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<T>
* @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<User>(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<Flyer[]>(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<Flyer[]>(response.body);
* expect(body.meta?.pagination?.page).toBe(1);
*/
export function asSuccessResponse<T>(body: unknown): ApiSuccessResponse<T> {
const parsed = body as ApiSuccessResponse<T> | 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<unknown> | 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<User>>(mockFn);
* typedMock.mockResolvedValue(mockUser);
*
* @example
* // Using with service method types
* vi.mock('@/services/userService');
* const mockCreate = vi.fn();
* someService.register(asMock<UserService['create']>(mockCreate));
*
* @example
* // Casting to access mock methods
* const unknownMock = someModule.someMethod;
* const typedMock = asMock<typeof originalFunction>(unknownMock);
* expect(typedMock).toHaveBeenCalledWith('expected-arg');
*/
export function asMock<T extends (...args: unknown[]) => 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<typeof supertest>;
}
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<CreateUserResult> => {
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 };
}
};