All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 25m5s
266 lines
10 KiB
TypeScript
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 };
|
|
}
|
|
};
|