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.
This commit is contained in:
Timothy Atapagra 2025-11-22 17:06:44 -05:00
parent 48fd77c46b
commit 269f5d2661
23 changed files with 793 additions and 335 deletions

View file

@ -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

View file

@ -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)
}

View file

@ -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'

View file

@ -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
/**

View file

@ -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'
}

View file

@ -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')

View file

@ -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 () => {

View file

@ -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<Entity> {
@ -102,16 +113,64 @@ export abstract class BaseFactory<Entity> {
protected abstract getRepository(): Repository<Entity>
}
/**
* 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<Entity>(
entityClass: new () => Entity,
): Repository<Entity> {
const app = FactoryRegistry.getApp()
if (app) {
// E2E test mode: Use NestJS DI (proper abstraction layer)
const repositoryToken = getRepositoryToken(entityClass)
return app.get<Repository<Entity>>(repositoryToken)
}
// Integration/migration test mode: Use global DataSource
const dataSource = getTestDataSource()
return dataSource.getRepository(entityClass)
}

View file

@ -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')

View file

@ -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 () => {

View file

@ -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<INestApplication> {
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<INestApplication> {
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<Repository<User>>(
* 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 }
}

View file

@ -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<T = any>(propertyPath: string, defaultValue?: T): T {
// Redirect DATABASE_* to TEST_DATABASE_*
const redirections: Record<string, string> = {
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<T>(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<T>(propertyPath, defaultValue)
}
}

View file

@ -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<T = any>(
app: INestApplication,
query: string,
parameters?: any[],
): Promise<T> {
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<void> {
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<void> {
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])
}

View file

@ -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<Repository<LibraryItemEntity>>(
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',

View file

@ -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<boolean>(
'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)
})

View file

@ -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<Repository<LibraryItemEntity>>(
getRepositoryToken(LibraryItemEntity),

View file

@ -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<Repository<LibraryItemEntity>>(
getRepositoryToken(LibraryItemEntity),
@ -86,7 +68,10 @@ describe('Notebook GraphQL (e2e)', () => {
await app.close()
}, 30000)
const executeQuery = (query: string, variables: Record<string, unknown> = {}) =>
const executeQuery = (
query: string,
variables: Record<string, unknown> = {},
) =>
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 () => {

View file

@ -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<Repository<LibraryItemEntity>>(
getRepositoryToken(LibraryItemEntity),

View file

@ -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<boolean>(
'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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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