mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
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:
parent
48fd77c46b
commit
269f5d2661
23 changed files with 793 additions and 335 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
/**
|
||||
|
|
|
|||
96
packages/api-nest/src/utils/env.utils.ts
Normal file
96
packages/api-nest/src/utils/env.utils.ts
Normal 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'
|
||||
}
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
159
packages/api-nest/test/helpers/create-e2e-app.ts
Normal file
159
packages/api-nest/test/helpers/create-e2e-app.ts
Normal 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 }
|
||||
}
|
||||
49
packages/api-nest/test/helpers/test-config.service.ts
Normal file
49
packages/api-nest/test/helpers/test-config.service.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
154
packages/api-nest/test/helpers/test-datasource.helper.ts
Normal file
154
packages/api-nest/test/helpers/test-datasource.helper.ts
Normal 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])
|
||||
}
|
||||
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
61
packages/api-nest/test/setup/test-db-constants.ts
Normal file
61
packages/api-nest/test/setup/test-db-constants.ts
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue