From 269f5d2661e0f8803bdbae2cc559242e786762ef Mon Sep 17 00:00:00 2001 From: Timothy Atapagra Date: Sat, 22 Nov 2025 17:06:44 -0500 Subject: [PATCH] refactor(api-nest): enhance test configuration and application setup for E2E tests - Introduced a new `createE2EApp` function to centralize the setup of the NestJS application for end-to-end tests, ensuring consistent configuration across test files. - Added a `TestConfigService` to redirect database configuration queries to test-specific environment variables, improving isolation during testing. - Implemented a `FactoryRegistry` to manage NestJS application instances for use in factory-based tests, allowing for dependency injection. - Updated various test files to utilize the new application setup functions, reducing redundancy and improving maintainability. - Enhanced error handling and logging in the test setup process to facilitate easier debugging and ensure proper environment configuration. These changes collectively streamline the testing process and improve the reliability of E2E tests across the application. --- packages/api-nest/src/app/app.module.ts | 5 +- packages/api-nest/src/config/test.config.ts | 88 +++++++++- packages/api-nest/src/main.ts | 3 +- .../api-nest/src/queue/queue.constants.ts | 5 +- packages/api-nest/src/utils/env.utils.ts | 96 +++++++++++ packages/api-nest/test/auth.e2e-spec.ts | 27 +-- .../test/factories-example.e2e-spec.ts | 10 +- .../api-nest/test/factories/base.factory.ts | 65 ++++++- packages/api-nest/test/graphql.e2e-spec.ts | 26 +-- packages/api-nest/test/health.e2e-spec.ts | 16 +- .../api-nest/test/helpers/create-e2e-app.ts | 159 ++++++++++++++++++ .../test/helpers/test-config.service.ts | 49 ++++++ .../test/helpers/test-datasource.helper.ts | 154 +++++++++++++++++ packages/api-nest/test/highlight.e2e-spec.ts | 32 +--- packages/api-nest/test/label.e2e-spec.ts | 110 +++--------- packages/api-nest/test/library.e2e-spec.ts | 28 +-- packages/api-nest/test/notebook.e2e-spec.ts | 40 ++--- .../test/reading-progress.e2e-spec.ts | 28 +-- packages/api-nest/test/save-url.e2e-spec.ts | 59 +------ packages/api-nest/test/setup/global-setup.ts | 31 +++- .../test/setup/jest-environment-setup.ts | 24 ++- .../api-nest/test/setup/test-db-constants.ts | 61 +++++++ .../api-nest/test/setup/testcontainers.ts | 12 +- 23 files changed, 793 insertions(+), 335 deletions(-) create mode 100644 packages/api-nest/src/utils/env.utils.ts create mode 100644 packages/api-nest/test/helpers/create-e2e-app.ts create mode 100644 packages/api-nest/test/helpers/test-config.service.ts create mode 100644 packages/api-nest/test/helpers/test-datasource.helper.ts create mode 100644 packages/api-nest/test/setup/test-db-constants.ts diff --git a/packages/api-nest/src/app/app.module.ts b/packages/api-nest/src/app/app.module.ts index 8dd4f0833..aa4e6cd73 100644 --- a/packages/api-nest/src/app/app.module.ts +++ b/packages/api-nest/src/app/app.module.ts @@ -20,7 +20,10 @@ import { configValidationSchema } from '../config/config.schema' // Global configuration with Joi validation ConfigModule.forRoot({ isGlobal: true, - envFilePath: ['.env.local', '.env'], + envFilePath: [ + `.env.${process.env.NODE_ENV || 'development'}`, // .env.test, .env.development, .env.production + '.env', // Fallback for any missing vars + ], validationSchema: configValidationSchema, validationOptions: { allowUnknown: true, // Allow other env vars not in schema diff --git a/packages/api-nest/src/config/test.config.ts b/packages/api-nest/src/config/test.config.ts index 281946e47..fbe13992e 100644 --- a/packages/api-nest/src/config/test.config.ts +++ b/packages/api-nest/src/config/test.config.ts @@ -13,13 +13,80 @@ import { EntityLabel } from '../label/entities/entity-label.entity' import { HighlightEntity } from '../highlight/entities/highlight.entity' import { ReadingProgressEntity } from '../reading-progress/entities/reading-progress.entity' -export const testDatabaseConfig: TypeOrmModuleOptions = { - type: 'postgres', - host: process.env.TEST_DATABASE_HOST, - port: Number.parseInt(process.env.TEST_DATABASE_PORT || '5432'), - username: process.env.TEST_DATABASE_USER, - password: process.env.TEST_DATABASE_PASSWORD, - database: process.env.TEST_DATABASE_NAME, +/** + * Validates test database configuration to prevent accidental production DB connections + * @param dbName - The database name to validate + * @returns The validated database name + * @throws Error if attempting to connect to production database or if name is missing + */ +function validateTestDatabaseName(dbName: string | undefined): string { + // Production database names that must never be used in tests + const FORBIDDEN_DB_NAMES = [ + 'omnivore', // Main production DB + 'omnivore_prod', // Production variant + 'omnivore_production', // Production variant + ] + + // If no database name provided, throw error + if (!dbName) { + throw new Error( + '🚨 CRITICAL: TEST_DATABASE_NAME is not set!\n' + + 'Tests cannot run without a database configuration.\n\n' + + 'For E2E tests with testcontainers (recommended):\n' + + ' - This should be set automatically by global-setup.ts\n' + + ' - Check that Jest globalSetup is configured correctly in jest-e2e.json\n\n' + + 'For manual test database:\n' + + ' - Copy .env.test.example to .env.test\n' + + ' - Set TEST_DATABASE_NAME=omnivore_test\n' + + ' - Create database: psql -U postgres -c "CREATE DATABASE omnivore_test;"\n' + + ' - Run migrations: TEST_DATABASE_NAME=omnivore_test npm run migration:run\n' + ) + } + + // Block production database names + const normalizedDbName = dbName.toLowerCase() + if (FORBIDDEN_DB_NAMES.includes(normalizedDbName)) { + throw new Error( + `🚨 CRITICAL: Tests attempting to connect to PRODUCTION database "${dbName}"!\n\n` + + `This would corrupt production data. Tests have been BLOCKED.\n\n` + + `Use a dedicated test database instead:\n` + + ` - For testcontainers: Global setup handles this automatically\n` + + ` - For manual DB: Set TEST_DATABASE_NAME=omnivore_test in .env.test\n\n` + + `Forbidden database names: ${FORBIDDEN_DB_NAMES.join(', ')}` + ) + } + + // Warn if database name doesn't contain 'test' (suspicious but not blocked) + if (!normalizedDbName.includes('test')) { + console.warn( + `āš ļø WARNING: Test database name "${dbName}" doesn't contain "test".\n` + + ` This might not be a dedicated test database. Recommended: omnivore_test\n` + ) + } + + return dbName +} + +/** + * Test database configuration for E2E tests + * Supports two modes: + * 1. Testcontainers (default): Ephemeral PostgreSQL container, auto-configured by global-setup.ts + * 2. Manual DB: Static test database specified in .env.test file + */ +const getTestDatabaseConfig = (): TypeOrmModuleOptions => { + const host = process.env.TEST_DATABASE_HOST || 'localhost' + const port = Number.parseInt(process.env.TEST_DATABASE_PORT || '5432') + const username = process.env.TEST_DATABASE_USER || 'postgres' + const password = process.env.TEST_DATABASE_PASSWORD || '' + const database = validateTestDatabaseName(process.env.TEST_DATABASE_NAME) + + return { + type: 'postgres', + host, + port, + username, + password, + database, entities: [ User, UserProfile, @@ -34,10 +101,13 @@ export const testDatabaseConfig: TypeOrmModuleOptions = { HighlightEntity, ReadingProgressEntity, ], - synchronize: false, - logging: false, + synchronize: false, // Schema managed by migrations + logging: false, // Reduce test noise + } } +export const testDatabaseConfig = getTestDatabaseConfig() + export const createTestDataSource = () => { return new DataSource(testDatabaseConfig as DataSourceOptions) } diff --git a/packages/api-nest/src/main.ts b/packages/api-nest/src/main.ts index 909753a42..669042699 100644 --- a/packages/api-nest/src/main.ts +++ b/packages/api-nest/src/main.ts @@ -1,7 +1,8 @@ -import { NestFactory } from '@nestjs/core' import { Logger, ValidationPipe } from '@nestjs/common' import { ConfigService } from '@nestjs/config' +import { NestFactory } from '@nestjs/core' import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger' + import { AppModule } from './app/app.module' import { EnvVariables } from './config/env-variables' diff --git a/packages/api-nest/src/queue/queue.constants.ts b/packages/api-nest/src/queue/queue.constants.ts index 19a859311..85e7b8c09 100644 --- a/packages/api-nest/src/queue/queue.constants.ts +++ b/packages/api-nest/src/queue/queue.constants.ts @@ -5,6 +5,8 @@ * to avoid magic strings throughout the codebase. */ +import { isTestEnvironment } from '../utils/env.utils' + /** * Queue Names - Define all queues used in the system */ @@ -83,7 +85,8 @@ export const REDIS_CONFIG = { ENABLE_READY_CHECK: true, ENABLE_OFFLINE_QUEUE: true, - KEY_PREFIX: process.env.NODE_ENV === 'test' ? 'omnivore:test:' : 'omnivore:', + // Use test-specific key prefix to avoid polluting production Redis during tests + KEY_PREFIX: isTestEnvironment() ? 'omnivore:test:' : 'omnivore:', } as const /** diff --git a/packages/api-nest/src/utils/env.utils.ts b/packages/api-nest/src/utils/env.utils.ts new file mode 100644 index 000000000..66546870b --- /dev/null +++ b/packages/api-nest/src/utils/env.utils.ts @@ -0,0 +1,96 @@ +/** + * Environment Detection Utilities + * + * Provides helper functions for detecting different runtime environments. + * These utilities handle both current and potential future test environment types. + */ + +/** + * Check if running in any test environment + * + * Covers both unit tests (NODE_ENV=test) and potential future test types (e2e, integration). + * Use this for general test-specific behavior like disabling external API calls, + * using test Redis keys, or enabling verbose logging. + * + * @returns true if NODE_ENV indicates a test environment + * + * @example + * ```typescript + * if (isTestEnvironment()) { + * // Use test Redis keys + * KEY_PREFIX = 'omnivore:test:' + * } + * ``` + */ +export function isTestEnvironment(): boolean { + const testEnvs = ['test', 'e2e', 'integration'] // Future-proof + return testEnvs.includes(process.env.NODE_ENV || '') +} + +/** + * Check if running E2E tests specifically + * + * E2E tests use testcontainers with full infrastructure (database, Redis, etc.). + * They set TEST_DATABASE_* environment variables at runtime. + * + * @returns true if running E2E tests with testcontainer + * + * @example + * ```typescript + * if (isE2EEnvironment()) { + * console.log('Running with testcontainer infrastructure') + * } + * ``` + */ +export function isE2EEnvironment(): boolean { + // E2E tests currently use NODE_ENV=test but can be distinguished + // by presence of TEST_DATABASE_* variables set by testcontainer + return process.env.NODE_ENV === 'test' && !!process.env.TEST_DATABASE_NAME +} + +/** + * Check if running unit tests specifically + * + * Unit tests mock all dependencies and don't use real infrastructure. + * They don't have TEST_DATABASE_* variables. + * + * @returns true if running unit tests (not E2E) + * + * @example + * ```typescript + * if (isUnitTestEnvironment()) { + * // All services should be mocked + * expect(mockService).toHaveBeenCalled() + * } + * ``` + */ +export function isUnitTestEnvironment(): boolean { + return process.env.NODE_ENV === 'test' && !process.env.TEST_DATABASE_NAME +} + +/** + * Check if running in development environment + * + * @returns true if NODE_ENV is 'development' + */ +export function isDevelopmentEnvironment(): boolean { + return process.env.NODE_ENV === 'development' +} + +/** + * Check if running in production environment + * + * @returns true if NODE_ENV is 'production' + */ +export function isProductionEnvironment(): boolean { + return process.env.NODE_ENV === 'production' +} + +/** + * Get the current environment name + * + * @returns The current NODE_ENV value or 'development' as default + */ +export function getEnvironment(): string { + return process.env.NODE_ENV || 'development' +} diff --git a/packages/api-nest/test/auth.e2e-spec.ts b/packages/api-nest/test/auth.e2e-spec.ts index a3142739c..24aa48fd0 100644 --- a/packages/api-nest/test/auth.e2e-spec.ts +++ b/packages/api-nest/test/auth.e2e-spec.ts @@ -1,10 +1,6 @@ -import { Test, TestingModule } from '@nestjs/testing' import { INestApplication } from '@nestjs/common' -import { ValidationPipe } from '@nestjs/common' -import { TypeOrmModule } from '@nestjs/typeorm' import request from 'supertest' -import { AppModule } from '../src/app/app.module' -import { testDatabaseConfig } from '../src/config/test.config' +import { createE2EApp } from './helpers/create-e2e-app' import { TEST_PERSONAS, INVALID_CREDENTIALS, @@ -31,26 +27,7 @@ describe('Authentication E2E Tests', () => { } beforeAll(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModule], - }) - .overrideModule(TypeOrmModule) - .useModule(TypeOrmModule.forRoot(testDatabaseConfig)) - .compile() - - app = moduleFixture.createNestApplication() - - // Apply the same pipes as main application - app.useGlobalPipes( - new ValidationPipe({ - whitelist: true, - forbidNonWhitelisted: true, - transform: true, - }), - ) - - app.setGlobalPrefix('api/v2') - await app.init() + app = await createE2EApp() // Create the main test user that login tests will use const mainTestUser = generateTestUser('main') diff --git a/packages/api-nest/test/factories-example.e2e-spec.ts b/packages/api-nest/test/factories-example.e2e-spec.ts index 914715b5f..9fd408d27 100644 --- a/packages/api-nest/test/factories-example.e2e-spec.ts +++ b/packages/api-nest/test/factories-example.e2e-spec.ts @@ -7,16 +7,16 @@ * Run with: yarn test:e2e --testPathPattern=factories-example */ +import { HighlightColor } from '../src/highlight/entities/highlight.entity' +import { StatusType } from '../src/user/entities/user.entity' +import { UserRole } from '../src/user/enums/user-role.enum' import { - UserFactory, - LibraryItemFactory, HighlightFactory, LabelFactory, + LibraryItemFactory, + UserFactory, } from './factories' import { getTestDataSource } from './setup/test-datasource' -import { UserRole } from '../src/user/enums/user-role.enum' -import { StatusType } from '../src/user/entities/user.entity' -import { HighlightColor } from '../src/highlight/entities/highlight.entity' describe('Factory Pattern Example (e2e)', () => { it('should create test data using factories', async () => { diff --git a/packages/api-nest/test/factories/base.factory.ts b/packages/api-nest/test/factories/base.factory.ts index e55853055..f12b37d2a 100644 --- a/packages/api-nest/test/factories/base.factory.ts +++ b/packages/api-nest/test/factories/base.factory.ts @@ -1,4 +1,6 @@ +import { INestApplication } from '@nestjs/common' import { DeepPartial, Repository } from 'typeorm' +import { getRepositoryToken } from '@nestjs/typeorm' import { getTestDataSource } from '../setup/test-datasource' /** @@ -7,13 +9,22 @@ import { getTestDataSource } from '../setup/test-datasource' * - build(): Creates entity in memory (for unit tests with mocks) * - create(): Saves entity to database (for integration/E2E tests) * + * Factory Initialization Modes: + * 1. E2E tests: Call FactoryRegistry.setApp(app) to use NestJS DI + * 2. Integration tests: Uses globalThis.__TEST_DATASOURCE__ automatically + * * @example * ```typescript + * // E2E test setup (recommended) + * beforeAll(async () => { + * app = await createE2EApp() + * FactoryRegistry.setApp(app) + * }) + * + * const user = await UserFactory.create({ email: 'test@example.com' }) + * * // Unit test (no database) * const user = UserFactory.build({ email: 'test@example.com' }) - * - * // Integration test (with database) - * const user = await UserFactory.create({ email: 'test@example.com' }) * ``` */ export abstract class BaseFactory { @@ -102,16 +113,64 @@ export abstract class BaseFactory { protected abstract getRepository(): Repository } +/** + * Factory Registry - Manages NestJS app instance for E2E tests + * + * E2E tests should call FactoryRegistry.setApp(app) in beforeAll + * to allow factories to use NestJS DI for repository access. + */ +export class FactoryRegistry { + private static app: INestApplication | null = null + + /** + * Set the NestJS app instance for E2E tests + * Call this in beforeAll() after creating your test app + * + * @param app - NestJS application instance + */ + static setApp(app: INestApplication): void { + FactoryRegistry.app = app + } + + /** + * Clear the app instance (call in afterAll) + */ + static clearApp(): void { + FactoryRegistry.app = null + } + + /** + * Get the current app instance + * @internal + */ + static getApp(): INestApplication | null { + return FactoryRegistry.app + } +} + /** * Helper function to get a repository from the test DataSource * Used by factory implementations * + * Behavior: + * - If E2E app is set (via FactoryRegistry.setApp), uses NestJS DI + * - Otherwise, falls back to globalThis.__TEST_DATASOURCE__ + * * @param entityClass - Entity class to get repository for * @returns TypeORM Repository instance */ export function getTestRepository( entityClass: new () => Entity, ): Repository { + const app = FactoryRegistry.getApp() + + if (app) { + // E2E test mode: Use NestJS DI (proper abstraction layer) + const repositoryToken = getRepositoryToken(entityClass) + return app.get>(repositoryToken) + } + + // Integration/migration test mode: Use global DataSource const dataSource = getTestDataSource() return dataSource.getRepository(entityClass) } diff --git a/packages/api-nest/test/graphql.e2e-spec.ts b/packages/api-nest/test/graphql.e2e-spec.ts index e0c9e32b8..95322db5e 100644 --- a/packages/api-nest/test/graphql.e2e-spec.ts +++ b/packages/api-nest/test/graphql.e2e-spec.ts @@ -1,33 +1,13 @@ -import { Test, TestingModule } from '@nestjs/testing' -import { INestApplication, ValidationPipe } from '@nestjs/common' -import { TypeOrmModule } from '@nestjs/typeorm' +import { INestApplication } from '@nestjs/common' import request from 'supertest' -import { AppModule } from '../src/app/app.module' -import { testDatabaseConfig } from '../src/config/test.config' +import { createE2EApp } from './helpers/create-e2e-app' describe('GraphQL Module (e2e)', () => { let app: INestApplication let authToken: string beforeAll(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModule], - }) - .overrideModule(TypeOrmModule) - .useModule(TypeOrmModule.forRoot(testDatabaseConfig)) - .compile() - - app = moduleFixture.createNestApplication() - app.useGlobalPipes( - new ValidationPipe({ - whitelist: true, - forbidNonWhitelisted: true, - transform: true, - }), - ) - - app.setGlobalPrefix('api/v2') - await app.init() + app = await createE2EApp() const registerResponse = await request(app.getHttpServer()) .post('/api/v2/auth/register') diff --git a/packages/api-nest/test/health.e2e-spec.ts b/packages/api-nest/test/health.e2e-spec.ts index f67655e5a..8eab0b135 100644 --- a/packages/api-nest/test/health.e2e-spec.ts +++ b/packages/api-nest/test/health.e2e-spec.ts @@ -1,24 +1,12 @@ -import { Test, TestingModule } from '@nestjs/testing' import { INestApplication } from '@nestjs/common' -import { TypeOrmModule } from '@nestjs/typeorm' import request from 'supertest' -import { AppModule } from '../src/app/app.module' -import { testDatabaseConfig } from '../src/config/test.config' +import { createE2EApp } from './helpers/create-e2e-app' describe('Health E2E Tests', () => { let app: INestApplication beforeAll(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModule], - }) - .overrideModule(TypeOrmModule) - .useModule(TypeOrmModule.forRoot(testDatabaseConfig)) - .compile() - - app = moduleFixture.createNestApplication() - app.setGlobalPrefix('api/v2') - await app.init() + app = await createE2EApp() }) afterAll(async () => { diff --git a/packages/api-nest/test/helpers/create-e2e-app.ts b/packages/api-nest/test/helpers/create-e2e-app.ts new file mode 100644 index 000000000..6eb502999 --- /dev/null +++ b/packages/api-nest/test/helpers/create-e2e-app.ts @@ -0,0 +1,159 @@ +import { INestApplication, ValidationPipe } from '@nestjs/common' +import { Test, TestingModule, TestingModuleBuilder } from '@nestjs/testing' +import { ConfigService } from '@nestjs/config' +import { AppModule } from '../../src/app/app.module' +import { TestConfigService } from './test-config.service' + +/** + * Create E2E Test Application + * + * Creates a fully configured NestJS application for E2E testing with: + * - TestConfigService override (redirects DATABASE_* to TEST_DATABASE_*) + * - Global validation pipes + * - API prefix + * + * This centralizes all E2E test setup to avoid repetition across test files. + * + * @returns Initialized NestJS application ready for testing + * + * @example + * ```typescript + * describe('My E2E Tests', () => { + * let app: INestApplication + * + * beforeAll(async () => { + * app = await createE2EApp() + * }) + * + * afterAll(async () => { + * await app.close() + * }) + * + * it('should work', async () => { + * const response = await request(app.getHttpServer()) + * .get('/api/v2/health') + * .expect(200) + * }) + * }) + * ``` + */ +export async function createE2EApp(): Promise { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }) + .overrideProvider(ConfigService) + .useClass(TestConfigService) + .compile() + + const app = moduleFixture.createNestApplication() + + // Apply global validation pipes (same as main.ts) + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ) + + // Set API prefix (same as main.ts) + app.setGlobalPrefix('api/v2') + + // Initialize the application + await app.init() + + return app +} + +/** + * Create E2E Test Application with Custom Configuration + * + * Same as createE2EApp() but allows customizing the TestingModule before compilation. + * Use this when you need to override additional providers or add custom setup. + * + * @param customize - Function to customize the TestingModuleBuilder + * @returns Initialized NestJS application ready for testing + * + * @example + * ```typescript + * const app = await createE2EAppWithCustomization(builder => + * builder + * .overrideProvider(MyService) + * .useValue(mockService) + * ) + * ``` + */ +export async function createE2EAppWithCustomization( + customize: (builder: TestingModuleBuilder) => TestingModuleBuilder, +): Promise { + let builder = Test.createTestingModule({ + imports: [AppModule], + }) + .overrideProvider(ConfigService) + .useClass(TestConfigService) + + // Apply custom modifications + builder = customize(builder) + + const moduleFixture: TestingModule = await builder.compile() + + const app = moduleFixture.createNestApplication() + + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ) + + app.setGlobalPrefix('api/v2') + + await app.init() + + return app +} + +/** + * Get module fixture for accessing providers directly + * + * Use this when you need access to repositories or services in your tests. + * + * @returns Module fixture and initialized application + * + * @example + * ```typescript + * const { app, moduleFixture } = await createE2EAppWithModule() + * + * const userRepository = moduleFixture.get>( + * getRepositoryToken(User) + * ) + * ``` + */ +export async function createE2EAppWithModule(): Promise<{ + app: INestApplication + moduleFixture: TestingModule +}> { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }) + .overrideProvider(ConfigService) + .useClass(TestConfigService) + .compile() + + const app = moduleFixture.createNestApplication() + + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ) + + app.setGlobalPrefix('api/v2') + + await app.init() + + return { app, moduleFixture } +} diff --git a/packages/api-nest/test/helpers/test-config.service.ts b/packages/api-nest/test/helpers/test-config.service.ts new file mode 100644 index 000000000..88b3b4f40 --- /dev/null +++ b/packages/api-nest/test/helpers/test-config.service.ts @@ -0,0 +1,49 @@ +import { Injectable } from '@nestjs/common' +import { ConfigService } from '@nestjs/config' + +/** + * Test ConfigService that redirects DATABASE_* queries to TEST_DATABASE_* values + * + * This ensures that when DatabaseModule reads DATABASE_* from ConfigService, + * it actually gets TEST_DATABASE_* values from the testcontainer. + * + * Usage in E2E tests: + * ```typescript + * .overrideProvider(ConfigService) + * .useClass(TestConfigService) + * ``` + */ +@Injectable() +export class TestConfigService extends ConfigService { + /** + * Intercept get() calls and redirect DATABASE_* to TEST_DATABASE_* + */ + get(propertyPath: string, defaultValue?: T): T { + // Redirect DATABASE_* to TEST_DATABASE_* + const redirections: Record = { + DATABASE_HOST: 'TEST_DATABASE_HOST', + DATABASE_PORT: 'TEST_DATABASE_PORT', + DATABASE_NAME: 'TEST_DATABASE_NAME', + DATABASE_USER: 'TEST_DATABASE_USER', + DATABASE_PASSWORD: 'TEST_DATABASE_PASSWORD', + } + + // If asking for DATABASE_*, return TEST_DATABASE_* instead + if (redirections[propertyPath]) { + const testKey = redirections[propertyPath] + const testValue = super.get(testKey, defaultValue) + + // Log for debugging (only in test environment) + if (process.env.NODE_ENV === 'test' && process.env.DEBUG_TEST_CONFIG) { + console.log( + `[TestConfigService] Redirecting ${propertyPath} → ${testKey} = ${testValue}`, + ) + } + + return testValue + } + + // For all other keys, use normal behavior + return super.get(propertyPath, defaultValue) + } +} diff --git a/packages/api-nest/test/helpers/test-datasource.helper.ts b/packages/api-nest/test/helpers/test-datasource.helper.ts new file mode 100644 index 000000000..df7bc78a8 --- /dev/null +++ b/packages/api-nest/test/helpers/test-datasource.helper.ts @@ -0,0 +1,154 @@ +import { DataSource } from 'typeorm' +import { INestApplication } from '@nestjs/common' + +/** + * Get Test DataSource from E2E App + * + * Returns the TypeORM DataSource configured for the test database (testcontainer). + * This is safer than importing test.config.ts directly. + * + * @param app - The E2E test application + * @returns DataSource connected to test database + * + * @example + * ```typescript + * describe('My E2E Tests', () => { + * let app: INestApplication + * let dataSource: DataSource + * + * beforeAll(async () => { + * app = await createE2EApp() + * dataSource = getTestDataSource(app) + * }) + * + * it('should insert test data', async () => { + * await dataSource.query( + * `INSERT INTO omnivore.library_item (...) VALUES (...)`, + * [...] + * ) + * }) + * }) + * ``` + */ +export function getTestDataSource(app: INestApplication): DataSource { + return app.get(DataSource) +} + +/** + * Execute Raw SQL Query in Test Database + * + * Convenience wrapper for executing raw SQL in E2E tests. + * + * @param app - The E2E test application + * @param query - SQL query to execute + * @param parameters - Query parameters (optional) + * @returns Query results + * + * @example + * ```typescript + * await executeTestQuery( + * app, + * `UPDATE omnivore.user SET status = $1 WHERE id = $2`, + * ['ACTIVE', userId] + * ) + * ``` + */ +export async function executeTestQuery( + app: INestApplication, + query: string, + parameters?: any[], +): Promise { + const dataSource = getTestDataSource(app) + return dataSource.query(query, parameters) +} + +/** + * Activate Test User (Skip Email Confirmation) + * + * Common helper to activate a newly registered test user without email confirmation. + * + * @param app - The E2E test application + * @param userId - User ID to activate + * + * @example + * ```typescript + * const registerResponse = await request(app.getHttpServer()) + * .post('/api/v2/auth/register') + * .send({ email, password, name }) + * + * await activateTestUser(app, registerResponse.body.user.id) + * ``` + */ +export async function activateTestUser( + app: INestApplication, + userId: string, +): Promise { + await executeTestQuery( + app, + `UPDATE omnivore.user SET status = 'ACTIVE' WHERE id = $1`, + [userId], + ) +} + +/** + * Clean Test Data + * + * Helper to clean up test data in the correct order (respecting foreign keys). + * + * @param app - The E2E test application + * @param userId - User ID to clean up data for + * + * @example + * ```typescript + * afterAll(async () => { + * await cleanTestData(app, userId) + * await app.close() + * }) + * ``` + */ +export async function cleanTestData( + app: INestApplication, + userId: string, +): Promise { + const dataSource = getTestDataSource(app) + + // Delete in correct order (child tables first, then parent) + await dataSource.query( + `DELETE FROM omnivore.entity_labels WHERE library_item_id IN + (SELECT id FROM omnivore.library_item WHERE user_id = $1)`, + [userId], + ) + + await dataSource.query( + `DELETE FROM omnivore.highlights WHERE library_item_id IN + (SELECT id FROM omnivore.library_item WHERE user_id = $1)`, + [userId], + ) + + await dataSource.query( + `DELETE FROM omnivore.reading_progress WHERE library_item_id IN + (SELECT id FROM omnivore.library_item WHERE user_id = $1)`, + [userId], + ) + + await dataSource.query( + `DELETE FROM omnivore.library_item WHERE user_id = $1`, + [userId], + ) + + await dataSource.query(`DELETE FROM omnivore.labels WHERE user_id = $1`, [ + userId, + ]) + + await dataSource.query( + `DELETE FROM omnivore.user_personalization WHERE user_id = $1`, + [userId], + ) + + await dataSource.query(`DELETE FROM omnivore.user_profile WHERE user_id = $1`, [ + userId, + ]) + + await dataSource.query(`DELETE FROM omnivore.user WHERE id = $1`, [userId]) +} + diff --git a/packages/api-nest/test/highlight.e2e-spec.ts b/packages/api-nest/test/highlight.e2e-spec.ts index 1efb13b1b..7b1cd420f 100644 --- a/packages/api-nest/test/highlight.e2e-spec.ts +++ b/packages/api-nest/test/highlight.e2e-spec.ts @@ -1,11 +1,9 @@ import { randomUUID } from 'crypto' -import { Test, TestingModule } from '@nestjs/testing' -import { INestApplication, ValidationPipe } from '@nestjs/common' -import { TypeOrmModule, getRepositoryToken } from '@nestjs/typeorm' +import { INestApplication } from '@nestjs/common' +import { getRepositoryToken } from '@nestjs/typeorm' import request from 'supertest' import { Repository } from 'typeorm' -import { AppModule } from '../src/app/app.module' -import { testDatabaseConfig } from '../src/config/test.config' +import { createE2EAppWithModule } from './helpers/create-e2e-app' import { ContentReaderType, LibraryItemEntity, @@ -98,24 +96,8 @@ describe('Highlight GraphQL (e2e)', () => { process.env.GOOGLE_CLIENT_SECRET = 'test-client-secret' process.env.JWT_SECRET = 'test-jwt-secret' - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModule], - }) - .overrideModule(TypeOrmModule) - .useModule(TypeOrmModule.forRoot(testDatabaseConfig)) - .compile() - - app = moduleFixture.createNestApplication() - app.useGlobalPipes( - new ValidationPipe({ - whitelist: true, - forbidNonWhitelisted: true, - transform: true, - }), - ) - - app.setGlobalPrefix('api/v2') - await app.init() + const { app: testApp, moduleFixture } = await createE2EAppWithModule() + app = testApp libraryRepository = moduleFixture.get>( getRepositoryToken(LibraryItemEntity), @@ -891,7 +873,9 @@ describe('Highlight GraphQL (e2e)', () => { // Verify fallback to textQuote selector from quote/prefix/suffix // GraphQL returns selectors as object, not string - expect(response.body.data.createHighlight.selectors.textQuote).toMatchObject({ + expect( + response.body.data.createHighlight.selectors.textQuote, + ).toMatchObject({ exact: 'simple highlight', prefix: 'before ', suffix: ' after', diff --git a/packages/api-nest/test/label.e2e-spec.ts b/packages/api-nest/test/label.e2e-spec.ts index b43f6b998..47cace7ad 100644 --- a/packages/api-nest/test/label.e2e-spec.ts +++ b/packages/api-nest/test/label.e2e-spec.ts @@ -1,10 +1,10 @@ -import { Test, TestingModule } from '@nestjs/testing' -import { INestApplication, ValidationPipe } from '@nestjs/common' -import request from 'supertest' +import { INestApplication } from '@nestjs/common' import { randomUUID } from 'crypto' -import { AppModule } from '../src/app/app.module' -import { ConfigService } from '@nestjs/config' -import { DataSource } from 'typeorm' +import request from 'supertest' +import { createE2EApp } from './helpers/create-e2e-app' +import { FactoryRegistry } from './factories/base.factory' +import { LibraryItemFactory } from './factories/library-item.factory' +import { LibraryItemState } from '../src/library/entities/library-item.entity' describe('Label E2E Tests', () => { let app: INestApplication @@ -14,23 +14,8 @@ describe('Label E2E Tests', () => { let testLibraryItemId: string beforeAll(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModule], - }).compile() - - app = moduleFixture.createNestApplication() - - // Use the same validation pipe configuration as main.ts - app.useGlobalPipes( - new ValidationPipe({ - whitelist: true, - forbidNonWhitelisted: true, - transform: true, - }), - ) - - app.setGlobalPrefix('api/v2') - await app.init() + app = await createE2EApp() + FactoryRegistry.setApp(app) // Enable factories to use NestJS DI // Create a test user and get auth token const testEmail = `test-label-${Date.now()}@example.com` @@ -48,75 +33,20 @@ describe('Label E2E Tests', () => { authToken = registerResponse.body.accessToken userId = registerResponse.body.user.id - // Get the config service to skip email confirmation - const configService = app.get(ConfigService) - const requireEmailConfirmation = configService.get( - 'AUTH_REQUIRE_EMAIL_CONFIRMATION', - ) - - // If email confirmation is required, confirm the email - if (requireEmailConfirmation) { - const dataSource = app.get(DataSource) - await dataSource.query( - `UPDATE omnivore.user SET status = 'ACTIVE' WHERE id = $1`, - [userId], - ) - } - - // Create a test library item for label associations - const libraryItemResponse = await executeQuery( - ` - mutation { - __typename - } - `, - {}, - ) - - // Use DataSource to create a library item directly - const dataSource = app.get(DataSource) - const libraryItemResult = await dataSource.query( - ` - INSERT INTO omnivore.library_item (id, user_id, title, slug, original_url, state, folder, saved_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) - RETURNING id - `, - [ - randomUUID(), - userId, - 'Test Article for Labels', - 'test-article-labels', - 'https://example.com/test-labels', - 'SUCCEEDED', - 'inbox', - ], - ) - testLibraryItemId = libraryItemResult[0].id + // Create a test library item for label associations using factory + const libraryItem = await LibraryItemFactory.create({ + userId, + title: 'Test Article for Labels', + slug: `test-article-labels-${Date.now()}`, + originalUrl: 'https://example.com/test-labels', + state: LibraryItemState.SUCCEEDED, + folder: 'inbox', + }) + testLibraryItemId = libraryItem.id }) afterAll(async () => { - // Clean up test data - if (userId) { - const dataSource = app.get(DataSource) - - // Delete in correct order due to foreign key constraints - await dataSource.query( - `DELETE FROM omnivore.entity_labels WHERE library_item_id = $1`, - [testLibraryItemId], - ) - await dataSource.query( - `DELETE FROM omnivore.labels WHERE user_id = $1`, - [userId], - ) - await dataSource.query( - `DELETE FROM omnivore.library_item WHERE user_id = $1`, - [userId], - ) - await dataSource.query(`DELETE FROM omnivore.user WHERE id = $1`, [ - userId, - ]) - } - + FactoryRegistry.clearApp() await app.close() }, 30000) // 30 second timeout for graceful BullMQ worker shutdown @@ -307,7 +237,7 @@ describe('Label E2E Tests', () => { expect(errorMessage).toBeDefined() expect( errorMessage.includes('hex color') || - errorMessage.includes('Bad Request') + errorMessage.includes('Bad Request'), ).toBe(true) }) diff --git a/packages/api-nest/test/library.e2e-spec.ts b/packages/api-nest/test/library.e2e-spec.ts index 50dc8081b..8758aa582 100644 --- a/packages/api-nest/test/library.e2e-spec.ts +++ b/packages/api-nest/test/library.e2e-spec.ts @@ -1,11 +1,9 @@ import { randomUUID } from 'crypto' -import { Test, TestingModule } from '@nestjs/testing' -import { INestApplication, ValidationPipe } from '@nestjs/common' -import { TypeOrmModule, getRepositoryToken } from '@nestjs/typeorm' +import { INestApplication } from '@nestjs/common' +import { getRepositoryToken } from '@nestjs/typeorm' import request from 'supertest' import { Repository } from 'typeorm' -import { AppModule } from '../src/app/app.module' -import { testDatabaseConfig } from '../src/config/test.config' +import { createE2EAppWithModule } from './helpers/create-e2e-app' import { ContentReaderType, LibraryItemEntity, @@ -142,24 +140,8 @@ describe('Library GraphQL (e2e)', () => { process.env.GOOGLE_CLIENT_SECRET = 'test-client-secret' process.env.JWT_SECRET = 'test-jwt-secret' - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModule], - }) - .overrideModule(TypeOrmModule) - .useModule(TypeOrmModule.forRoot(testDatabaseConfig)) - .compile() - - app = moduleFixture.createNestApplication() - app.useGlobalPipes( - new ValidationPipe({ - whitelist: true, - forbidNonWhitelisted: true, - transform: true, - }), - ) - - app.setGlobalPrefix('api/v2') - await app.init() + const { app: testApp, moduleFixture } = await createE2EAppWithModule() + app = testApp libraryRepository = moduleFixture.get>( getRepositoryToken(LibraryItemEntity), diff --git a/packages/api-nest/test/notebook.e2e-spec.ts b/packages/api-nest/test/notebook.e2e-spec.ts index 963fe1dde..b289cffaf 100644 --- a/packages/api-nest/test/notebook.e2e-spec.ts +++ b/packages/api-nest/test/notebook.e2e-spec.ts @@ -1,11 +1,9 @@ import { randomUUID } from 'crypto' -import { Test, TestingModule } from '@nestjs/testing' -import { INestApplication, ValidationPipe } from '@nestjs/common' -import { TypeOrmModule, getRepositoryToken } from '@nestjs/typeorm' +import { INestApplication } from '@nestjs/common' +import { getRepositoryToken } from '@nestjs/typeorm' import request from 'supertest' import { Repository } from 'typeorm' -import { AppModule } from '../src/app/app.module' -import { testDatabaseConfig } from '../src/config/test.config' +import { createE2EAppWithModule } from './helpers/create-e2e-app' import { ContentReaderType, LibraryItemEntity, @@ -46,24 +44,8 @@ describe('Notebook GraphQL (e2e)', () => { process.env.GOOGLE_CLIENT_SECRET = 'test-client-secret' process.env.JWT_SECRET = 'test-jwt-secret' - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModule], - }) - .overrideModule(TypeOrmModule) - .useModule(TypeOrmModule.forRoot(testDatabaseConfig)) - .compile() - - app = moduleFixture.createNestApplication() - app.useGlobalPipes( - new ValidationPipe({ - whitelist: true, - forbidNonWhitelisted: true, - transform: true, - }), - ) - - app.setGlobalPrefix('api/v2') - await app.init() + const { app: testApp, moduleFixture } = await createE2EAppWithModule() + app = testApp libraryRepository = moduleFixture.get>( getRepositoryToken(LibraryItemEntity), @@ -86,7 +68,10 @@ describe('Notebook GraphQL (e2e)', () => { await app.close() }, 30000) - const executeQuery = (query: string, variables: Record = {}) => + const executeQuery = ( + query: string, + variables: Record = {}, + ) => request(app.getHttpServer()) .post('/api/graphql') .set('Authorization', `Bearer ${authToken}`) @@ -117,7 +102,8 @@ describe('Notebook GraphQL (e2e)', () => { }) it('creates a new notebook for a library item', async () => { - const noteContent = '# My Thoughts\n\nThis is an interesting article about TypeScript.' + const noteContent = + '# My Thoughts\n\nThis is an interesting article about TypeScript.' const response = await executeQuery(UPDATE_NOTEBOOK_MUTATION, { id: testItemId, @@ -268,7 +254,9 @@ describe('Notebook GraphQL (e2e)', () => { secondResponse.body.data.updateNotebook.noteUpdatedAt, ) - expect(secondTimestamp.getTime()).toBeGreaterThan(firstTimestamp.getTime()) + expect(secondTimestamp.getTime()).toBeGreaterThan( + firstTimestamp.getTime(), + ) }) it('preserves notebook when updating other library item fields', async () => { diff --git a/packages/api-nest/test/reading-progress.e2e-spec.ts b/packages/api-nest/test/reading-progress.e2e-spec.ts index e01262d1c..62b0b285b 100644 --- a/packages/api-nest/test/reading-progress.e2e-spec.ts +++ b/packages/api-nest/test/reading-progress.e2e-spec.ts @@ -1,11 +1,9 @@ import { randomUUID } from 'crypto' -import { Test, TestingModule } from '@nestjs/testing' -import { INestApplication, ValidationPipe } from '@nestjs/common' -import { TypeOrmModule, getRepositoryToken } from '@nestjs/typeorm' +import { INestApplication } from '@nestjs/common' +import { getRepositoryToken } from '@nestjs/typeorm' import request from 'supertest' import { Repository } from 'typeorm' -import { AppModule } from '../src/app/app.module' -import { testDatabaseConfig } from '../src/config/test.config' +import { createE2EAppWithModule } from './helpers/create-e2e-app' import { ContentReaderType, LibraryItemEntity, @@ -57,24 +55,8 @@ describe('ReadingProgress GraphQL (e2e)', () => { process.env.GOOGLE_CLIENT_SECRET = 'test-client-secret' process.env.JWT_SECRET = 'test-jwt-secret' - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModule], - }) - .overrideModule(TypeOrmModule) - .useModule(TypeOrmModule.forRoot(testDatabaseConfig)) - .compile() - - app = moduleFixture.createNestApplication() - app.useGlobalPipes( - new ValidationPipe({ - whitelist: true, - forbidNonWhitelisted: true, - transform: true, - }), - ) - - app.setGlobalPrefix('api/v2') - await app.init() + const { app: testApp, moduleFixture } = await createE2EAppWithModule() + app = testApp libraryRepository = moduleFixture.get>( getRepositoryToken(LibraryItemEntity), diff --git a/packages/api-nest/test/save-url.e2e-spec.ts b/packages/api-nest/test/save-url.e2e-spec.ts index 6c082b096..7ed90457d 100644 --- a/packages/api-nest/test/save-url.e2e-spec.ts +++ b/packages/api-nest/test/save-url.e2e-spec.ts @@ -1,10 +1,7 @@ -import { Test, TestingModule } from '@nestjs/testing' -import { INestApplication, ValidationPipe } from '@nestjs/common' +import { INestApplication } from '@nestjs/common' import request from 'supertest' -import { randomUUID } from 'crypto' -import { AppModule } from '../src/app/app.module' -import { ConfigService } from '@nestjs/config' -import { DataSource } from 'typeorm' +import { createE2EApp } from './helpers/create-e2e-app' +import { FactoryRegistry } from './factories/base.factory' import { FOLDERS } from '../src/constants/folders.constants' describe('SaveUrl E2E Tests', () => { @@ -14,23 +11,8 @@ describe('SaveUrl E2E Tests', () => { let createdLibraryItemIds: string[] = [] beforeAll(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModule], - }).compile() - - app = moduleFixture.createNestApplication() - - // Use the same validation pipe configuration as main.ts - app.useGlobalPipes( - new ValidationPipe({ - whitelist: true, - forbidNonWhitelisted: true, - transform: true, - }), - ) - - app.setGlobalPrefix('api/v2') - await app.init() + app = await createE2EApp() + FactoryRegistry.setApp(app) // Enable factories to use NestJS DI // Create a test user and get auth token const testEmail = `test-saveurl-${Date.now()}@example.com` @@ -48,37 +30,12 @@ describe('SaveUrl E2E Tests', () => { authToken = registerResponse.body.accessToken userId = registerResponse.body.user.id - // Get the config service to skip email confirmation - const configService = app.get(ConfigService) - const requireEmailConfirmation = configService.get( - 'AUTH_REQUIRE_EMAIL_CONFIRMATION', - ) - - // If email confirmation is required, confirm the email - if (requireEmailConfirmation) { - const dataSource = app.get(DataSource) - await dataSource.query( - `UPDATE omnivore.user SET status = 'ACTIVE' WHERE id = $1`, - [userId], - ) - } + // Note: In test mode, users are ACTIVE by default (no email confirmation required) + // This is configured via TEST_AUTH_REQUIRE_EMAIL_CONFIRMATION=false }) afterAll(async () => { - // Clean up test data - if (userId) { - const dataSource = app.get(DataSource) - - // Delete in correct order due to foreign key constraints - await dataSource.query( - `DELETE FROM omnivore.library_item WHERE user_id = $1`, - [userId], - ) - await dataSource.query(`DELETE FROM omnivore.user WHERE id = $1`, [ - userId, - ]) - } - + FactoryRegistry.clearApp() await app.close() }, 30000) // 30 second timeout for graceful BullMQ worker shutdown diff --git a/packages/api-nest/test/setup/global-setup.ts b/packages/api-nest/test/setup/global-setup.ts index 7f117f17b..ecc5bfaa6 100644 --- a/packages/api-nest/test/setup/global-setup.ts +++ b/packages/api-nest/test/setup/global-setup.ts @@ -1,4 +1,5 @@ import { setupTestContainer } from './testcontainers' +import { TEST_DB_ENV_VARS } from './test-db-constants' /** * Jest Global Setup @@ -9,15 +10,35 @@ export default async function globalSetup() { console.log('\nšŸš€ Jest Global Setup - Starting test infrastructure...\n') try { + // Validate environment before starting + if (process.env.NODE_ENV && process.env.NODE_ENV !== 'test') { + console.warn( + `\nāš ļø WARNING: NODE_ENV is "${process.env.NODE_ENV}" but should be "test".\n` + + ` E2E tests expect NODE_ENV=test for library compatibility.\n` + + ` Setting NODE_ENV=test now...\n`, + ) + } + + // Set NODE_ENV to test FIRST so ConfigModule loads .env.test + process.env.NODE_ENV = 'test' + console.log('āœ… Environment: NODE_ENV=test (E2E test mode)') + // Start container and initialize database const { container, dataSource } = await setupTestContainer() // Store connection details in environment variables for test workers - process.env.TEST_DB_HOST = container.getHost() - process.env.TEST_DB_PORT = container.getPort().toString() - process.env.TEST_DB_DATABASE = container.getDatabase() - process.env.TEST_DB_USERNAME = container.getUsername() - process.env.TEST_DB_PASSWORD = container.getPassword() + // Using TEST_DATABASE_* naming (from TEST_DB_ENV_VARS constants) + process.env[TEST_DB_ENV_VARS.HOST] = container.getHost() + process.env[TEST_DB_ENV_VARS.PORT] = container.getPort().toString() + process.env[TEST_DB_ENV_VARS.NAME] = container.getDatabase() + process.env[TEST_DB_ENV_VARS.USER] = container.getUsername() + process.env[TEST_DB_ENV_VARS.PASSWORD] = container.getPassword() + + console.log('\nšŸ”§ Test database connection:') + console.log(` Host: ${container.getHost()}`) + console.log(` Port: ${container.getPort()}`) + console.log(` Database: ${container.getDatabase()}`) + console.log(` User: ${container.getUsername()}\n`) // Store for global teardown // @ts-ignore - globalThis extension diff --git a/packages/api-nest/test/setup/jest-environment-setup.ts b/packages/api-nest/test/setup/jest-environment-setup.ts index 87460dcf0..b993db4c3 100644 --- a/packages/api-nest/test/setup/jest-environment-setup.ts +++ b/packages/api-nest/test/setup/jest-environment-setup.ts @@ -22,15 +22,23 @@ import { HighlightEntity } from '../../src/highlight/entities/highlight.entity' * This runs once per test worker */ async function initializeTestDataSource() { - // Get connection details set by global setup - const host = process.env.TEST_DB_HOST - const port = parseInt(process.env.TEST_DB_PORT || '5432') - const database = process.env.TEST_DB_DATABASE - const username = process.env.TEST_DB_USERNAME - const password = process.env.TEST_DB_PASSWORD + // Get connection details set by global setup (testcontainer mode) + // or from .env.test file (manual DB mode) + const host = process.env.TEST_DATABASE_HOST + const port = Number(process.env.TEST_DATABASE_PORT) + const database = process.env.TEST_DATABASE_NAME + const username = process.env.TEST_DATABASE_USER + const password = process.env.TEST_DATABASE_PASSWORD - if (!host || !database || !username || !password) { - throw new Error('Test database connection details not found. Make sure globalSetup ran successfully.') + if (!host || !database || !username) { + throw new Error( + 'Test database connection details not found.\n' + + 'Expected environment variables: TEST_DATABASE_HOST, TEST_DATABASE_PORT, TEST_DATABASE_NAME, TEST_DATABASE_USER, TEST_DATABASE_PASSWORD\n\n' + + 'Make sure:\n' + + '1. Global setup ran successfully (testcontainer mode), OR\n' + + '2. .env.test file exists with correct values (manual DB mode)\n\n' + + 'See .env.test.example for configuration template.', + ) } // Create and initialize DataSource diff --git a/packages/api-nest/test/setup/test-db-constants.ts b/packages/api-nest/test/setup/test-db-constants.ts new file mode 100644 index 000000000..00bed11f1 --- /dev/null +++ b/packages/api-nest/test/setup/test-db-constants.ts @@ -0,0 +1,61 @@ +/** + * Test Database Constants + * + * Centralized constants for testcontainer database configuration. + * These values are used by: + * - global-setup.ts (to create testcontainer) + * - Tests (if needed for assertions) + * + * DO NOT change these values unless you have a good reason. + * They are specifically chosen for test isolation. + */ + +export const TEST_DB_CONSTANTS = { + /** + * Database name for test container + * Must contain 'test' for safety validation + */ + DATABASE_NAME: 'test_omnivore', + + /** + * Username for test database + * Non-privileged user for security + */ + USERNAME: 'test_user', + + /** + * Password for test database + * Simple password is fine for ephemeral containers + */ + PASSWORD: 'test_password', + + /** + * PostgreSQL version + * Match production for accurate testing + */ + POSTGRES_VERSION: 'postgres:15-alpine', + + /** + * Container port + * Standard PostgreSQL port + */ + PORT: 5432, + + /** + * Schema name + * Must match production schema + */ + SCHEMA: 'omnivore', +} as const + +/** + * Environment variable names for test database configuration + * These are set by global-setup.ts and read by TestConfigService + */ +export const TEST_DB_ENV_VARS = { + HOST: 'TEST_DATABASE_HOST', + PORT: 'TEST_DATABASE_PORT', + NAME: 'TEST_DATABASE_NAME', + USER: 'TEST_DATABASE_USER', + PASSWORD: 'TEST_DATABASE_PASSWORD', +} as const diff --git a/packages/api-nest/test/setup/testcontainers.ts b/packages/api-nest/test/setup/testcontainers.ts index ebe93a2dc..315d47d70 100644 --- a/packages/api-nest/test/setup/testcontainers.ts +++ b/packages/api-nest/test/setup/testcontainers.ts @@ -1,4 +1,8 @@ -import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql' +import { TEST_DB_CONSTANTS } from './test-db-constants' +import { + PostgreSqlContainer, + StartedPostgreSqlContainer, +} from '@testcontainers/postgresql' import { DataSource } from 'typeorm' import { User } from '../../src/user/entities/user.entity' import { UserProfile } from '../../src/user/entities/profile.entity' @@ -66,9 +70,11 @@ export async function setupTestContainer(): Promise<{ console.log('šŸ”Œ Initializing database connection...') await dataSource.initialize() - console.log('šŸ“ Creating omnivore schema...') + console.log(`šŸ“ Creating ${TEST_DB_CONSTANTS.SCHEMA} schema...`) // Create the omnivore schema that our entities use - await dataSource.query('CREATE SCHEMA IF NOT EXISTS omnivore') + await dataSource.query( + `CREATE SCHEMA IF NOT EXISTS ${TEST_DB_CONSTANTS.SCHEMA}`, + ) console.log('šŸ”„ Synchronizing database schema...') // Now synchronize will create tables in the omnivore schema