From 73cf90b4eab5e386e92e7e3786c37424ba6b78e7 Mon Sep 17 00:00:00 2001 From: Timothy Atapagra Date: Sat, 18 Oct 2025 11:46:34 -0400 Subject: [PATCH] feat(api-nest): add test data factories and setup for E2E testing - Introduced various factory classes (UserFactory, LibraryItemFactory, HighlightFactory, LabelFactory) to streamline test data generation using Faker.js. - Implemented global setup and teardown scripts for initializing and cleaning up PostgreSQL containers during tests. - Created a base factory class to provide common methods for building and creating entities in memory or in the database. - Updated Jest configuration to include global setup and teardown, enhancing test isolation and environment management. --- packages/api-nest/.env.test.example | 69 ++++++ packages/api-nest/package.json | 2 + .../test/factories-example.e2e-spec.ts | 99 ++++++++ .../api-nest/test/factories/base.factory.ts | 117 +++++++++ .../test/factories/highlight.factory.ts | 144 +++++++++++ packages/api-nest/test/factories/index.ts | 26 ++ .../api-nest/test/factories/label.factory.ts | 118 +++++++++ .../test/factories/library-item.factory.ts | 168 +++++++++++++ .../api-nest/test/factories/user.factory.ts | 113 +++++++++ packages/api-nest/test/jest-e2e.json | 8 +- packages/api-nest/test/setup/global-setup.ts | 33 +++ .../api-nest/test/setup/global-teardown.ts | 23 ++ .../test/setup/jest-environment-setup.ts | 81 +++++++ .../api-nest/test/setup/test-datasource.ts | 20 ++ .../api-nest/test/setup/testcontainers.ts | 160 +++++++++++++ yarn.lock | 226 +++++++++++++++++- 16 files changed, 1400 insertions(+), 7 deletions(-) create mode 100644 packages/api-nest/.env.test.example create mode 100644 packages/api-nest/test/factories-example.e2e-spec.ts create mode 100644 packages/api-nest/test/factories/base.factory.ts create mode 100644 packages/api-nest/test/factories/highlight.factory.ts create mode 100644 packages/api-nest/test/factories/index.ts create mode 100644 packages/api-nest/test/factories/label.factory.ts create mode 100644 packages/api-nest/test/factories/library-item.factory.ts create mode 100644 packages/api-nest/test/factories/user.factory.ts create mode 100644 packages/api-nest/test/setup/global-setup.ts create mode 100644 packages/api-nest/test/setup/global-teardown.ts create mode 100644 packages/api-nest/test/setup/jest-environment-setup.ts create mode 100644 packages/api-nest/test/setup/test-datasource.ts create mode 100644 packages/api-nest/test/setup/testcontainers.ts diff --git a/packages/api-nest/.env.test.example b/packages/api-nest/.env.test.example new file mode 100644 index 000000000..0f0273326 --- /dev/null +++ b/packages/api-nest/.env.test.example @@ -0,0 +1,69 @@ +# Test Environment Configuration +# Copy this file to .env.test and update values as needed + +# ========================================== +# DATABASE CONFIGURATION (Test Database) +# ========================================== +# CRITICAL: Use separate test database to prevent polluting development data +TEST_DATABASE_NAME=omnivore_test +TEST_DATABASE_HOST=localhost +TEST_DATABASE_PORT=5432 +TEST_DATABASE_USER=app_user +TEST_DATABASE_PASSWORD= + +# ========================================== +# REDIS CONFIGURATION (Test Redis) +# ========================================== +# Recommended: Use separate Redis database number for tests (0-15) +REDIS_URL=redis://localhost:6379/1 +# Or use separate Redis instance: +# TEST_REDIS_HOST=localhost +# TEST_REDIS_PORT=6380 + +# ========================================== +# AUTHENTICATION & SECURITY +# ========================================== +# Use test credentials (NOT production values) +JWT_SECRET=test-jwt-secret-change-me +JWT_EXPIRATION=24h + +# Google OAuth (Test Credentials) +GOOGLE_CLIENT_ID=test-google-client-id +GOOGLE_CLIENT_SECRET=test-google-client-secret +GOOGLE_CALLBACK_URL=http://localhost:4001/api/v2/auth/google/callback + +# ========================================== +# APPLICATION SETTINGS +# ========================================== +NODE_ENV=test +PORT=4001 +API_BASE_URL=http://localhost:4001 + +# ========================================== +# LOGGING +# ========================================== +# Reduce log noise during tests +LOG_LEVEL=error +ENABLE_QUERY_LOGGING=false + +# ========================================== +# QUEUE & BACKGROUND JOBS +# ========================================== +# Use lower concurrency for tests +QUEUE_WORKER_CONCURRENCY=1 +QUEUE_JOB_ATTEMPTS=1 + +# ========================================== +# CONTENT PROCESSING (Test Settings) +# ========================================== +# Use faster/simpler settings for tests +CONTENT_FETCH_TIMEOUT=5000 +MAX_CONTENT_SIZE=1048576 + +# ========================================== +# NOTES FOR DEVELOPERS +# ========================================== +# 1. Never commit .env.test with real credentials +# 2. Run migrations before tests: TEST_DATABASE_NAME=omnivore_test npm run migration:run +# 3. Create test database: psql -U postgres -c "CREATE DATABASE omnivore_test;" +# 4. See TESTING.md for complete setup instructions diff --git a/packages/api-nest/package.json b/packages/api-nest/package.json index ddc7b3032..16efae45e 100644 --- a/packages/api-nest/package.json +++ b/packages/api-nest/package.json @@ -64,9 +64,11 @@ "typeorm": "^0.3.17" }, "devDependencies": { + "@faker-js/faker": "^10.1.0", "@nestjs/cli": "^10.0.0", "@nestjs/schematics": "^10.0.0", "@nestjs/testing": "^10.0.0", + "@testcontainers/postgresql": "^11.7.1", "@types/bcrypt": "^5.0.2", "@types/express": "^4.17.17", "@types/jest": "^29.5.14", diff --git a/packages/api-nest/test/factories-example.e2e-spec.ts b/packages/api-nest/test/factories-example.e2e-spec.ts new file mode 100644 index 000000000..a013d8297 --- /dev/null +++ b/packages/api-nest/test/factories-example.e2e-spec.ts @@ -0,0 +1,99 @@ +/** + * Factory Pattern Example E2E Test + * + * This test demonstrates using Testcontainers + Factories + * for easy, isolated test data generation. + * + * Run with: yarn test:e2e --testPathPattern=factories-example + */ + +import { UserFactory, LibraryItemFactory, HighlightFactory, LabelFactory } 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' + +describe('Factory Pattern Example (e2e)', () => { + it('should create test data using factories', async () => { + // Verify testcontainer datasource is available + const dataSource = getTestDataSource() + expect(dataSource.isInitialized).toBe(true) + + // Create a user + const user = await UserFactory.create({ + email: 'factory-test@example.com', + name: 'Factory Test User', + }) + + expect(user.id).toBeDefined() + expect(user.email).toBe('factory-test@example.com') + expect(user.password).toBeDefined() // bcrypt hash + + // Create a library item for this user + const item = await LibraryItemFactory.create({ + userId: user.id, + title: 'Amazing Article About Testing', + }) + + expect(item.id).toBeDefined() + expect(item.userId).toBe(user.id) + expect(item.title).toBe('Amazing Article About Testing') + expect(item.slug).toBeDefined() // Slug is auto-generated by factory + + // Create highlights for the article + const highlight1 = await HighlightFactory.withColor(item.id, user.id, 'yellow') + const highlight2 = await HighlightFactory.withColor(item.id, user.id, 'red') + + expect(highlight1.color).toBe('yellow') + expect(highlight2.color).toBe('red') + expect(highlight1.quote).toBeDefined() // Faker-generated + + // Create labels + const labels = await LabelFactory.createManyForUser(user.id, 3) + + expect(labels).toHaveLength(3) + expect(labels[0].name).toBeDefined() + expect(labels[0].color).toMatch(/^#[0-9A-F]{6}$/i) // Hex color + + console.log('โœ… Successfully created test data with factories!') + console.log(' - User:', user.email) + console.log(' - Library Item:', item.title) + console.log(' - Highlights:', 2) + console.log(' - Labels:', labels.length) + }) + + it('should build entities in memory (for unit tests)', () => { + // Build without saving to database (for mocking) + const user = UserFactory.build({ + email: 'mock@example.com', + role: UserRole.ADMIN, + }) + + expect(user.email).toBe('mock@example.com') + expect(user.role).toBe(UserRole.ADMIN) + // No database call was made + + const item = LibraryItemFactory.buildArchived(user.id) + expect(item.folder).toBe('archive') + expect(item.state).toBe('ARCHIVED') + // No database call was made + }) + + it('should use factory helper methods', async () => { + const user = await UserFactory.create() + + // Helper methods for common scenarios + const adminUser = await UserFactory.admin() + expect(adminUser.role).toBe(UserRole.ADMIN) + + const pendingUser = await UserFactory.pending() + expect(pendingUser.status).toBe(StatusType.PENDING) + + const archivedItem = await LibraryItemFactory.archived(user.id) + expect(archivedItem.folder).toBe('archive') + + const itemWithProgress = await LibraryItemFactory.withProgress(user.id, 75) + expect(itemWithProgress.readingProgressTopPercent).toBe(75) + + console.log('โœ… Factory helper methods work perfectly!') + }) +}) diff --git a/packages/api-nest/test/factories/base.factory.ts b/packages/api-nest/test/factories/base.factory.ts new file mode 100644 index 000000000..e55853055 --- /dev/null +++ b/packages/api-nest/test/factories/base.factory.ts @@ -0,0 +1,117 @@ +import { DeepPartial, Repository } from 'typeorm' +import { getTestDataSource } from '../setup/test-datasource' + +/** + * Base Factory for generating test data + * Provides two methods: + * - build(): Creates entity in memory (for unit tests with mocks) + * - create(): Saves entity to database (for integration/E2E tests) + * + * @example + * ```typescript + * // 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 { + /** + * Build an entity in memory without saving to database + * Useful for unit tests where you mock repositories + * + * @param overrides - Partial entity properties to override defaults + * @returns Entity instance (not saved to DB) + */ + build(overrides?: DeepPartial): Entity { + const defaults = this.generateDefaults() + return { + ...defaults, + ...(overrides || {}), + } as Entity + } + + /** + * Create and save an entity to the test database + * Useful for integration/E2E tests that need real database data + * + * @param overrides - Partial entity properties to override defaults + * @returns Promise of saved entity instance + */ + async create(overrides?: DeepPartial): Promise { + const entity = this.build(overrides) + const repository = this.getRepository() + return await repository.save(entity as any) + } + + /** + * Create multiple entities in the database + * Efficient for seeding test data + * + * @param count - Number of entities to create + * @param overrides - Properties to apply to all entities + * @returns Promise of array of saved entities + */ + async createMany( + count: number, + overrides?: DeepPartial, + ): Promise { + const entities: Entity[] = [] + + for (let i = 0; i < count; i++) { + entities.push(await this.create(overrides)) + } + + return entities + } + + /** + * Build multiple entities in memory + * + * @param count - Number of entities to build + * @param overrides - Properties to apply to all entities + * @returns Array of entity instances (not saved) + */ + buildMany(count: number, overrides?: DeepPartial): Entity[] { + const entities: Entity[] = [] + + for (let i = 0; i < count; i++) { + entities.push(this.build(overrides)) + } + + return entities + } + + /** + * Generate default properties for the entity + * Must be implemented by each factory to provide entity-specific defaults + * + * @returns Partial entity with default properties + * @protected + */ + protected abstract generateDefaults(): DeepPartial + + /** + * Get the TypeORM repository for this entity + * Used by create() to save to database + * + * @returns TypeORM Repository instance + * @protected + */ + protected abstract getRepository(): Repository +} + +/** + * Helper function to get a repository from the test DataSource + * Used by factory implementations + * + * @param entityClass - Entity class to get repository for + * @returns TypeORM Repository instance + */ +export function getTestRepository( + entityClass: new () => Entity, +): Repository { + const dataSource = getTestDataSource() + return dataSource.getRepository(entityClass) +} diff --git a/packages/api-nest/test/factories/highlight.factory.ts b/packages/api-nest/test/factories/highlight.factory.ts new file mode 100644 index 000000000..dacbf40c2 --- /dev/null +++ b/packages/api-nest/test/factories/highlight.factory.ts @@ -0,0 +1,144 @@ +import { faker } from '@faker-js/faker' +import { Repository } from 'typeorm' +import { + HighlightEntity, + HighlightType, +} from '../../src/highlight/entities/highlight.entity' +import { BaseFactory, getTestRepository } from './base.factory' + +/** + * HighlightFactory - Generate test highlights + * + * @example + * ```typescript + * // Create a highlight + * const highlight = await HighlightFactory.create({ + * libraryItemId: item.id, + * userId: user.id + * }) + * + * // Use helper methods + * const redHighlight = await HighlightFactory.withColor(item.id, user.id, 'red') + * ``` + */ +class HighlightFactoryClass extends BaseFactory { + protected generateDefaults() { + const shortTimestamp = Date.now().toString().slice(-8) + + return { + id: faker.string.uuid(), + shortId: `h${shortTimestamp}${faker.string.alphanumeric(2)}`, + quote: faker.lorem.sentence(), + prefix: faker.lorem.words(3), + suffix: faker.lorem.words(3), + highlightPositionPercent: faker.number.int({ min: 10, max: 90 }), + highlightPositionAnchorIndex: faker.number.int({ min: 0, max: 100 }), + color: 'yellow', + highlightType: HighlightType.HIGHLIGHT, + createdAt: new Date(), + updatedAt: new Date(), + // These will be set by the caller + libraryItemId: '', // Must be provided + userId: '', // Must be provided + libraryItem: undefined, + user: undefined, + } + } + + protected getRepository(): Repository { + return getTestRepository(HighlightEntity) + } + + /** + * Create a highlight with a specific color + */ + async withColor( + libraryItemId: string, + userId: string, + color: string, + overrides: Partial = {}, + ): Promise { + return this.create({ + libraryItemId, + userId, + color, + ...overrides, + }) + } + + /** + * Create a highlight with annotation + */ + async withAnnotation( + libraryItemId: string, + userId: string, + annotation: string, + overrides: Partial = {}, + ): Promise { + return this.create({ + libraryItemId, + userId, + annotation, + ...overrides, + }) + } + + /** + * Create a redacted highlight + */ + async redacted( + libraryItemId: string, + userId: string, + overrides: Partial = {}, + ): Promise { + return this.create({ + libraryItemId, + userId, + highlightType: HighlightType.REDACTION, + ...overrides, + }) + } + + /** + * Create multiple highlights for an article + */ + async createManyForArticle( + libraryItemId: string, + userId: string, + count: number, + ): Promise { + const highlights: HighlightEntity[] = [] + + for (let i = 0; i < count; i++) { + highlights.push( + await this.create({ + libraryItemId, + userId, + highlightPositionPercent: (i + 1) * (100 / (count + 1)), + }), + ) + } + + return highlights + } + + /** + * Build highlight with color (in memory) + */ + buildWithColor( + libraryItemId: string, + userId: string, + color: string, + overrides: Partial = {}, + ): HighlightEntity { + return this.build({ + libraryItemId, + userId, + color, + ...overrides, + }) + } +} + +// Export singleton instance +export const HighlightFactory = new HighlightFactoryClass() diff --git a/packages/api-nest/test/factories/index.ts b/packages/api-nest/test/factories/index.ts new file mode 100644 index 000000000..3f3f362d7 --- /dev/null +++ b/packages/api-nest/test/factories/index.ts @@ -0,0 +1,26 @@ +/** + * Test Data Factories + * + * Provides easy-to-use factories for generating test data with Faker.js + * Supports both in-memory entity creation (build) and database persistence (create). + * + * @example + * ```typescript + * import { UserFactory, LibraryItemFactory } from './factories' + * + * // Create test user in database + * const user = await UserFactory.create({ email: 'test@example.com' }) + * + * // Create test library item + * const item = await LibraryItemFactory.create({ + * userId: user.id, + * title: 'Test Article' + * }) + * ``` + */ + +export { UserFactory } from './user.factory' +export { LibraryItemFactory } from './library-item.factory' +export { HighlightFactory } from './highlight.factory' +export { LabelFactory } from './label.factory' +export { BaseFactory } from './base.factory' diff --git a/packages/api-nest/test/factories/label.factory.ts b/packages/api-nest/test/factories/label.factory.ts new file mode 100644 index 000000000..9111eafb2 --- /dev/null +++ b/packages/api-nest/test/factories/label.factory.ts @@ -0,0 +1,118 @@ +import { faker } from '@faker-js/faker' +import { Repository } from 'typeorm' +import { Label } from '../../src/label/entities/label.entity' +import { BaseFactory, getTestRepository } from './base.factory' + +/** + * LabelFactory - Generate test labels + * + * @example + * ```typescript + * // Create a label + * const label = await LabelFactory.create({ + * userId: user.id, + * name: 'Important' + * }) + * + * // Use helper methods + * const internalLabel = await LabelFactory.internal(user.id, 'RSS') + * ``` + */ +class LabelFactoryClass extends BaseFactory