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.
This commit is contained in:
Timothy Atapagra 2025-10-18 11:46:34 -04:00
parent b0e508fe4e
commit 73cf90b4ea
16 changed files with 1400 additions and 7 deletions

View file

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

View file

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

View file

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

View file

@ -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<Entity> {
/**
* 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>): 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<Entity>): Promise<Entity> {
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<Entity>,
): Promise<Entity[]> {
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>): 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<Entity>
/**
* Get the TypeORM repository for this entity
* Used by create() to save to database
*
* @returns TypeORM Repository instance
* @protected
*/
protected abstract getRepository(): Repository<Entity>
}
/**
* 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<Entity>(
entityClass: new () => Entity,
): Repository<Entity> {
const dataSource = getTestDataSource()
return dataSource.getRepository(entityClass)
}

View file

@ -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<HighlightEntity> {
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<HighlightEntity> {
return getTestRepository(HighlightEntity)
}
/**
* Create a highlight with a specific color
*/
async withColor(
libraryItemId: string,
userId: string,
color: string,
overrides: Partial<HighlightEntity> = {},
): Promise<HighlightEntity> {
return this.create({
libraryItemId,
userId,
color,
...overrides,
})
}
/**
* Create a highlight with annotation
*/
async withAnnotation(
libraryItemId: string,
userId: string,
annotation: string,
overrides: Partial<HighlightEntity> = {},
): Promise<HighlightEntity> {
return this.create({
libraryItemId,
userId,
annotation,
...overrides,
})
}
/**
* Create a redacted highlight
*/
async redacted(
libraryItemId: string,
userId: string,
overrides: Partial<HighlightEntity> = {},
): Promise<HighlightEntity> {
return this.create({
libraryItemId,
userId,
highlightType: HighlightType.REDACTION,
...overrides,
})
}
/**
* Create multiple highlights for an article
*/
async createManyForArticle(
libraryItemId: string,
userId: string,
count: number,
): Promise<HighlightEntity[]> {
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> = {},
): HighlightEntity {
return this.build({
libraryItemId,
userId,
color,
...overrides,
})
}
}
// Export singleton instance
export const HighlightFactory = new HighlightFactoryClass()

View file

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

View file

@ -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<Label> {
private static labelColors = [
'#FF5733',
'#33FF57',
'#3357FF',
'#FF33F5',
'#F5FF33',
'#33FFF5',
]
protected generateDefaults() {
return {
id: faker.string.uuid(),
name: faker.word.adjective() + '-' + faker.word.noun(),
color: faker.helpers.arrayElement(LabelFactoryClass.labelColors),
description: faker.lorem.sentence(),
position: faker.number.int({ min: 0, max: 100 }),
internal: false,
createdAt: new Date(),
updatedAt: new Date(),
// Must be set by caller
userId: '', // Must be provided
user: undefined,
}
}
protected getRepository(): Repository<Label> {
return getTestRepository(Label)
}
/**
* Create an internal label (system-created, not deletable)
*/
async internal(
userId: string,
name: string,
overrides: Partial<Label> = {},
): Promise<Label> {
return this.create({
userId,
name,
internal: true,
...overrides,
})
}
/**
* Create a label with a specific color
*/
async withColor(
userId: string,
color: string,
overrides: Partial<Label> = {},
): Promise<Label> {
return this.create({
userId,
color,
...overrides,
})
}
/**
* Create multiple labels for a user
*/
async createManyForUser(userId: string, count: number): Promise<Label[]> {
const labels: Label[] = []
for (let i = 0; i < count; i++) {
labels.push(
await this.create({
userId,
position: i,
}),
)
}
return labels
}
/**
* Build internal label (in memory)
*/
buildInternal(
userId: string,
name: string,
overrides: Partial<Label> = {},
): Label {
return this.build({
userId,
name,
internal: true,
...overrides,
})
}
}
// Export singleton instance
export const LabelFactory = new LabelFactoryClass()

View file

@ -0,0 +1,168 @@
import { faker } from '@faker-js/faker'
import { Repository } from 'typeorm'
import {
LibraryItemEntity,
LibraryItemState,
ContentReaderType,
} from '../../src/library/entities/library-item.entity'
import { BaseFactory, getTestRepository } from './base.factory'
import { FOLDERS } from '../../src/constants/folders.constants'
/**
* LibraryItemFactory - Generate test library items
*
* @example
* ```typescript
* // Create a library item
* const item = await LibraryItemFactory.create({
* userId: user.id,
* title: 'Test Article'
* })
*
* // Use helper methods
* const archivedItem = await LibraryItemFactory.archived(user.id)
* const deletedItem = await LibraryItemFactory.deleted(user.id)
* ```
*/
class LibraryItemFactoryClass extends BaseFactory<LibraryItemEntity> {
protected generateDefaults() {
const timestamp = Date.now()
const title = faker.lorem.sentence()
return {
id: faker.string.uuid(),
title,
slug: faker.helpers.slugify(title).toLowerCase() + `-${timestamp}`,
originalUrl: faker.internet.url(),
savedAt: new Date(),
state: LibraryItemState.SUCCEEDED,
folder: FOLDERS.INBOX,
contentReader: ContentReaderType.WEB,
itemType: 'ARTICLE',
createdAt: new Date(),
updatedAt: new Date(),
// These will be set by the caller
userId: '', // Must be provided
user: undefined,
}
}
protected getRepository(): Repository<LibraryItemEntity> {
return getTestRepository(LibraryItemEntity)
}
/**
* Create an archived library item
*/
async archived(userId: string, overrides: Partial<LibraryItemEntity> = {}): Promise<LibraryItemEntity> {
return this.create({
userId,
folder: FOLDERS.ARCHIVE,
state: LibraryItemState.ARCHIVED,
...overrides,
})
}
/**
* Create a deleted library item (in trash)
*/
async deleted(userId: string, overrides: Partial<LibraryItemEntity> = {}): Promise<LibraryItemEntity> {
return this.create({
userId,
folder: FOLDERS.TRASH,
state: LibraryItemState.DELETED,
...overrides,
})
}
/**
* Create an item with reading progress
*/
async withProgress(
userId: string,
percentComplete: number,
overrides: Partial<LibraryItemEntity> = {},
): Promise<LibraryItemEntity> {
const readAt = percentComplete === 100 ? new Date() : null
return this.create({
userId,
readingProgressTopPercent: percentComplete,
readingProgressBottomPercent: Math.min(percentComplete + 5, 100),
readAt,
...overrides,
})
}
/**
* Create an item that's still being processed
*/
async processing(userId: string, overrides: Partial<LibraryItemEntity> = {}): Promise<LibraryItemEntity> {
return this.create({
userId,
state: LibraryItemState.CONTENT_NOT_FETCHED,
title: faker.internet.url(), // Temporary title (URL)
...overrides,
})
}
/**
* Create an item with a notebook
*/
async withNotebook(
userId: string,
noteContent: string,
overrides: Partial<LibraryItemEntity> = {},
): Promise<LibraryItemEntity> {
return this.create({
userId,
note: noteContent,
noteUpdatedAt: new Date(),
...overrides,
})
}
/**
* Create a PDF library item
*/
async pdf(userId: string, overrides: Partial<LibraryItemEntity> = {}): Promise<LibraryItemEntity> {
return this.create({
userId,
contentReader: ContentReaderType.PDF,
itemType: 'FILE',
...overrides,
})
}
/**
* Build archived item (in memory)
*/
buildArchived(userId: string, overrides: Partial<LibraryItemEntity> = {}): LibraryItemEntity {
return this.build({
userId,
folder: FOLDERS.ARCHIVE,
state: LibraryItemState.ARCHIVED,
...overrides,
})
}
/**
* Build item with progress (in memory)
*/
buildWithProgress(
userId: string,
percentComplete: number,
overrides: Partial<LibraryItemEntity> = {},
): LibraryItemEntity {
return this.build({
userId,
readingProgressTopPercent: percentComplete,
readingProgressBottomPercent: Math.min(percentComplete + 5, 100),
readAt: percentComplete === 100 ? new Date() : null,
...overrides,
})
}
}
// Export singleton instance
export const LibraryItemFactory = new LibraryItemFactoryClass()

View file

@ -0,0 +1,113 @@
import { faker } from '@faker-js/faker'
import { Repository } from 'typeorm'
import { User, StatusType, RegistrationType } from '../../src/user/entities/user.entity'
import { UserRole } from '../../src/user/enums/user-role.enum'
import { BaseFactory, getTestRepository } from './base.factory'
import * as bcrypt from 'bcrypt'
/**
* UserFactory - Generate test user data
*
* @example
* ```typescript
* // Create a user in the database
* const user = await UserFactory.create({ email: 'test@example.com' })
*
* // Build a user in memory (for mocking)
* const user = UserFactory.build({ role: 'admin' })
*
* // Use helper methods
* const admin = await UserFactory.admin()
* const pendingUser = await UserFactory.pending()
* ```
*/
class UserFactoryClass extends BaseFactory<User> {
protected generateDefaults() {
const firstName = faker.person.firstName()
const lastName = faker.person.lastName()
return {
id: faker.string.uuid(),
sourceUserId: faker.string.uuid(), // Required unique identifier
email: faker.internet.email({ firstName, lastName }).toLowerCase(),
name: `${firstName} ${lastName}`,
password: bcrypt.hashSync('password123', 10), // Default password
role: UserRole.USER,
status: StatusType.ACTIVE,
source: RegistrationType.EMAIL,
membership: 'REGULAR',
createdAt: new Date(),
updatedAt: new Date(),
}
}
protected getRepository(): Repository<User> {
return getTestRepository(User)
}
/**
* Create an admin user
*/
async admin(overrides: Partial<User> = {}): Promise<User> {
return this.create({
role: UserRole.ADMIN,
...overrides,
})
}
/**
* Create a pending user (email not yet verified)
*/
async pending(overrides: Partial<User> = {}): Promise<User> {
return this.create({
status: StatusType.PENDING,
...overrides,
})
}
/**
* Create an archived user
*/
async archived(overrides: Partial<User> = {}): Promise<User> {
return this.create({
status: StatusType.ARCHIVED,
...overrides,
})
}
/**
* Create a user with a specific password (for login tests)
*/
async withPassword(
password: string,
overrides: Partial<User> = {},
): Promise<User> {
return this.create({
password: bcrypt.hashSync(password, 10),
...overrides,
})
}
/**
* Build admin user (in memory, not saved)
*/
buildAdmin(overrides: Partial<User> = {}): User {
return this.build({
role: UserRole.ADMIN,
...overrides,
})
}
/**
* Build pending user (in memory, not saved)
*/
buildPending(overrides: Partial<User> = {}): User {
return this.build({
status: StatusType.PENDING,
...overrides,
})
}
}
// Export singleton instance
export const UserFactory = new UserFactoryClass()

View file

@ -7,7 +7,7 @@
"^.+\\.(t|j)s$": "ts-jest"
},
"transformIgnorePatterns": [
"node_modules/(?!(bullmq|msgpackr)/)"
"node_modules/(?!(bullmq|msgpackr|@faker-js)/)"
],
"moduleDirectories": [
"node_modules",
@ -17,5 +17,9 @@
"moduleNameMapper": {
"^typeorm$": "<rootDir>/../../../node_modules/typeorm",
"^uuid$": "<rootDir>/../../../node_modules/uuid/dist/index.js"
}
},
"globalSetup": "<rootDir>/setup/global-setup.ts",
"globalTeardown": "<rootDir>/setup/global-teardown.ts",
"setupFilesAfterEnv": ["<rootDir>/setup/jest-environment-setup.ts"],
"testTimeout": 60000
}

View file

@ -0,0 +1,33 @@
import { setupTestContainer } from './testcontainers'
/**
* Jest Global Setup
* Runs once before all test suites
* Starts PostgreSQL container and initializes database
*/
export default async function globalSetup() {
console.log('\n🚀 Jest Global Setup - Starting test infrastructure...\n')
try {
// 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()
// Store for global teardown
// @ts-ignore - globalThis extension
globalThis.__TEST_CONTAINER__ = container
// @ts-ignore
globalThis.__TEST_DATASOURCE__ = dataSource
console.log('\n✅ Global setup complete!\n')
} catch (error) {
console.error('❌ Global setup failed:', error)
throw error
}
}

View file

@ -0,0 +1,23 @@
import { teardownTestContainer } from './testcontainers'
/**
* Jest Global Teardown
* Runs once after all test suites complete
* Stops PostgreSQL container and cleans up resources
*/
export default async function globalTeardown() {
console.log('\n🧹 Jest Global Teardown - Cleaning up test infrastructure...\n')
try {
await teardownTestContainer()
// Clean up global reference
// @ts-ignore - globalThis extension
delete globalThis.__TEST_DATASOURCE__
console.log('\n✅ Global teardown complete!\n')
} catch (error) {
console.error('❌ Global teardown failed:', error)
// Don't throw - let tests complete even if cleanup fails
}
}

View file

@ -0,0 +1,81 @@
/**
* Jest Environment Setup
* Runs in each test worker to initialize the test DataSource
*/
import { DataSource } from 'typeorm'
import { User } from '../../src/user/entities/user.entity'
import { UserProfile } from '../../src/user/entities/profile.entity'
import { UserPersonalization } from '../../src/user/entities/user-personalization.entity'
import { Filter } from '../../src/filter/entities/filter.entity'
import { Group } from '../../src/group/entities/group.entity'
import { Invite } from '../../src/group/entities/invite.entity'
import { GroupMembership } from '../../src/group/entities/group-membership.entity'
import { LibraryItemEntity } from '../../src/library/entities/library-item.entity'
import { Label } from '../../src/label/entities/label.entity'
import { EntityLabel } from '../../src/label/entities/entity-label.entity'
import { HighlightEntity } from '../../src/highlight/entities/highlight.entity'
/**
* Initialize test DataSource using connection details from global setup
* 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
if (!host || !database || !username || !password) {
throw new Error('Test database connection details not found. Make sure globalSetup ran successfully.')
}
// Create and initialize DataSource
const dataSource = new DataSource({
type: 'postgres',
host,
port,
database,
username,
password,
entities: [
User,
UserProfile,
UserPersonalization,
Filter,
Group,
Invite,
GroupMembership,
LibraryItemEntity,
Label,
EntityLabel,
HighlightEntity,
],
synchronize: false, // Schema already created by global setup
logging: false,
})
await dataSource.initialize()
// Store in globalThis for factories to access
// @ts-ignore
globalThis.__TEST_DATASOURCE__ = dataSource
return dataSource
}
// Initialize immediately when this file is loaded
beforeAll(async () => {
await initializeTestDataSource()
})
// Clean up after all tests in this worker
afterAll(async () => {
// @ts-ignore
const dataSource = globalThis.__TEST_DATASOURCE__ as DataSource
if (dataSource && dataSource.isInitialized) {
await dataSource.destroy()
}
})

View file

@ -0,0 +1,20 @@
import { DataSource } from 'typeorm'
/**
* Get the current test DataSource instance
* This is set by the global setup and used by factories
*
* This file does NOT import testcontainers, avoiding ESM import issues in Jest
*/
export function getTestDataSource(): DataSource {
// @ts-ignore - DataSource is set by global setup
const dataSource = globalThis.__TEST_DATASOURCE__ as DataSource
if (!dataSource || !dataSource.isInitialized) {
throw new Error(
'Test DataSource not initialized. Make sure globalSetup has run.',
)
}
return dataSource
}

View file

@ -0,0 +1,160 @@
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'
import { UserPersonalization } from '../../src/user/entities/user-personalization.entity'
import { Filter } from '../../src/filter/entities/filter.entity'
import { Group } from '../../src/group/entities/group.entity'
import { Invite } from '../../src/group/entities/invite.entity'
import { GroupMembership } from '../../src/group/entities/group-membership.entity'
import { LibraryItemEntity } from '../../src/library/entities/library-item.entity'
import { Label } from '../../src/label/entities/label.entity'
import { EntityLabel } from '../../src/label/entities/entity-label.entity'
import { HighlightEntity } from '../../src/highlight/entities/highlight.entity'
let container: StartedPostgreSqlContainer | null = null
let dataSource: DataSource | null = null
/**
* Start PostgreSQL container and initialize DataSource with migrations
* This is called once at the start of the entire test run
*/
export async function setupTestContainer(): Promise<{
container: StartedPostgreSqlContainer
dataSource: DataSource
}> {
console.log('🐳 Starting PostgreSQL test container...')
// Start PostgreSQL 15 container
container = await new PostgreSqlContainer('postgres:15-alpine')
.withDatabase('test_omnivore')
.withUsername('test_user')
.withPassword('test_password')
.withExposedPorts(5432)
.start()
console.log(`✅ PostgreSQL container started on port ${container.getPort()}`)
// Create DataSource with all entities
dataSource = new DataSource({
type: 'postgres',
host: container.getHost(),
port: container.getPort(),
database: container.getDatabase(),
username: container.getUsername(),
password: container.getPassword(),
entities: [
User,
UserProfile,
UserPersonalization,
Filter,
Group,
Invite,
GroupMembership,
LibraryItemEntity,
Label,
EntityLabel,
HighlightEntity,
],
synchronize: false, // We'll call synchronize() manually after creating schema
logging: false, // Disable logging for cleaner test output
dropSchema: false, // Don't drop schema (we want it to persist across tests)
})
console.log('🔌 Initializing database connection...')
await dataSource.initialize()
console.log('📁 Creating omnivore schema...')
// Create the omnivore schema that our entities use
await dataSource.query('CREATE SCHEMA IF NOT EXISTS omnivore')
console.log('🔄 Synchronizing database schema...')
// Now synchronize will create tables in the omnivore schema
await dataSource.synchronize()
console.log('✅ Test database ready!')
return { container, dataSource }
}
/**
* Stop PostgreSQL container and close DataSource
* This is called once at the end of the entire test run
*/
export async function teardownTestContainer(): Promise<void> {
console.log('🧹 Cleaning up test container...')
if (dataSource && dataSource.isInitialized) {
await dataSource.destroy()
console.log('✅ DataSource closed')
}
if (container) {
await container.stop()
console.log('✅ PostgreSQL container stopped')
}
}
/**
* Get the current DataSource instance
* Used by factories and test helpers
*/
export function getTestDataSource(): DataSource {
if (!dataSource || !dataSource.isInitialized) {
throw new Error(
'Test DataSource not initialized. Make sure globalSetup has run.',
)
}
return dataSource
}
/**
* Begin a transaction for test isolation
* Call this in beforeEach hooks
*/
export async function beginTestTransaction(): Promise<void> {
const ds = getTestDataSource()
await ds.query('BEGIN')
}
/**
* Rollback transaction to restore database state
* Call this in afterEach hooks
*/
export async function rollbackTestTransaction(): Promise<void> {
const ds = getTestDataSource()
await ds.query('ROLLBACK')
}
/**
* Clean all tables (alternative to transaction rollback)
* Useful for E2E tests that need to commit transactions
*/
export async function cleanDatabase(): Promise<void> {
const ds = getTestDataSource()
// Disable foreign key checks temporarily
await ds.query('SET session_replication_role = replica')
// Truncate all tables
const tables = [
'omnivore.entity_label',
'omnivore.highlight',
'omnivore.label',
'omnivore.library_item',
'omnivore.group_membership',
'omnivore.invite',
'omnivore.group',
'omnivore.filter',
'omnivore.user_personalization',
'omnivore.user_profile',
'omnivore.user',
]
for (const table of tables) {
await ds.query(`TRUNCATE TABLE ${table} CASCADE`)
}
// Re-enable foreign key checks
await ds.query('SET session_replication_role = DEFAULT')
}

226
yarn.lock
View file

@ -2276,6 +2276,11 @@
"@babel/helper-string-parser" "^7.27.1"
"@babel/helper-validator-identifier" "^7.27.1"
"@balena/dockerignore@^1.0.2":
version "1.0.2"
resolved "https://registry.yarnpkg.com/@balena/dockerignore/-/dockerignore-1.0.2.tgz#9ffe4726915251e8eb69f44ef3547e0da2c03e0d"
integrity sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==
"@base2/pretty-print-object@1.0.1":
version "1.0.1"
resolved "https://registry.npmjs.org/@base2/pretty-print-object/-/pretty-print-object-1.0.1.tgz"
@ -2749,6 +2754,11 @@
"@eslint/core" "^0.15.2"
levn "^0.4.1"
"@faker-js/faker@^10.1.0":
version "10.1.0"
resolved "https://registry.yarnpkg.com/@faker-js/faker/-/faker-10.1.0.tgz#eb72869d01ccbff41a77aa7ac851ce1ac9371129"
integrity sha512-C3mrr3b5dRVlKPJdfrAXS8+dq+rq8Qm5SNRazca0JKgw1HQERFmrVb0towvMmw5uu8hHKNiQasMaR/tydf3Zsg==
"@fast-csv/parse@^5.0.0":
version "5.0.5"
resolved "https://registry.npmjs.org/@fast-csv/parse/-/parse-5.0.5.tgz"
@ -3582,6 +3592,14 @@
"@grpc/proto-loader" "^0.7.13"
"@js-sdsl/ordered-map" "^4.4.2"
"@grpc/grpc-js@^1.11.1":
version "1.14.0"
resolved "https://registry.yarnpkg.com/@grpc/grpc-js/-/grpc-js-1.14.0.tgz#a3c47e7816ca2b4d5490cba9e06a3cf324e675ad"
integrity sha512-N8Jx6PaYzcTRNzirReJCtADVoq4z7+1KQ4E70jTg/koQiMoUSN1kbNjPOqpPbhMFhfU1/l7ixspPl8dNY+FoUg==
dependencies:
"@grpc/proto-loader" "^0.8.0"
"@js-sdsl/ordered-map" "^4.4.2"
"@grpc/proto-loader@^0.7.0", "@grpc/proto-loader@^0.7.13":
version "0.7.15"
resolved "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz"
@ -8918,6 +8936,13 @@
dependencies:
"@tanstack/query-core" "5.90.2"
"@testcontainers/postgresql@^11.7.1":
version "11.7.1"
resolved "https://registry.yarnpkg.com/@testcontainers/postgresql/-/postgresql-11.7.1.tgz#707a39f618528da7c5eb96ea899c538d56e1ec97"
integrity sha512-8PfGNqwdyoMPQuubZM0wd07/tfi4vhLAjXP791tM105vSCmzCOhLfYu2CIq04GKVlmW1J5z5nOZWLNlU9WrUuQ==
dependencies:
testcontainers "^11.7.1"
"@testing-library/cypress@^8.0.2":
version "8.0.7"
resolved "https://registry.npmjs.org/@testing-library/cypress/-/cypress-8.0.7.tgz"
@ -9295,6 +9320,23 @@
resolved "https://registry.npmjs.org/@types/diff-match-patch/-/diff-match-patch-1.0.36.tgz"
integrity sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==
"@types/docker-modem@*":
version "3.0.6"
resolved "https://registry.yarnpkg.com/@types/docker-modem/-/docker-modem-3.0.6.tgz#1f9262fcf85425b158ca725699a03eb23cddbf87"
integrity sha512-yKpAGEuKRSS8wwx0joknWxsmLha78wNMe9R2S3UNsVOkZded8UqOrV8KoeDXoXsjndxwyF3eIhyClGbO1SEhEg==
dependencies:
"@types/node" "*"
"@types/ssh2" "*"
"@types/dockerode@^3.3.44":
version "3.3.44"
resolved "https://registry.yarnpkg.com/@types/dockerode/-/dockerode-3.3.44.tgz#1e6d5b291646820e9daabfa132cdb33c9d535b56"
integrity sha512-fUpIHlsbYpxAJb285xx3vp7q5wf5mjqSn3cYwl/MhiM+DB99OdO5sOCPlO0PjO+TyOtphPs7tMVLU/RtOo/JjA==
dependencies:
"@types/docker-modem" "*"
"@types/node" "*"
"@types/ssh2" "*"
"@types/dompurify@^2.4.0":
version "2.4.0"
resolved "https://registry.npmjs.org/@types/dompurify/-/dompurify-2.4.0.tgz"
@ -10180,6 +10222,28 @@
resolved "https://registry.npmjs.org/@types/source-list-map/-/source-list-map-0.1.6.tgz"
integrity sha512-5JcVt1u5HDmlXkwOD2nslZVllBBc7HDuOICfiZah2Z0is8M8g+ddAEawbmd3VjedfDHBzxCaXLs07QEmb7y54g==
"@types/ssh2-streams@*":
version "0.1.12"
resolved "https://registry.yarnpkg.com/@types/ssh2-streams/-/ssh2-streams-0.1.12.tgz#e68795ba2bf01c76b93f9c9809e1f42f0eaaec5f"
integrity sha512-Sy8tpEmCce4Tq0oSOYdfqaBpA3hDM8SoxoFh5vzFsu2oL+znzGz8oVWW7xb4K920yYMUY+PIG31qZnFMfPWNCg==
dependencies:
"@types/node" "*"
"@types/ssh2@*":
version "1.15.5"
resolved "https://registry.yarnpkg.com/@types/ssh2/-/ssh2-1.15.5.tgz#6d8f45db2f39519b8d9377268fa71ed77d969686"
integrity sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==
dependencies:
"@types/node" "^18.11.18"
"@types/ssh2@^0.5.48":
version "0.5.52"
resolved "https://registry.yarnpkg.com/@types/ssh2/-/ssh2-0.5.52.tgz#9dbd8084e2a976e551d5e5e70b978ed8b5965741"
integrity sha512-lbLLlXxdCZOSJMCInKH2+9V/77ET2J6NPQHpFI0kda61Dd1KglJs+fPQBchizmzYSOJBgdTajhPqBO1xxLywvg==
dependencies:
"@types/node" "*"
"@types/ssh2-streams" "*"
"@types/stack-utils@^2.0.0", "@types/stack-utils@^2.0.3":
version "2.0.3"
resolved "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz"
@ -12195,7 +12259,7 @@ asn1.js@^5.3.0:
minimalistic-assert "^1.0.0"
safer-buffer "^2.1.0"
asn1@~0.2.3:
asn1@^0.2.6, asn1@~0.2.3:
version "0.2.6"
resolved "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz"
integrity sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==
@ -12278,6 +12342,11 @@ async-function@^1.0.0:
resolved "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz"
integrity sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==
async-lock@^1.4.1:
version "1.4.1"
resolved "https://registry.yarnpkg.com/async-lock/-/async-lock-1.4.1.tgz#56b8718915a9b68b10fce2f2a9a3dddf765ef53f"
integrity sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==
async-retry@^1.2.1, async-retry@^1.3.3:
version "1.3.3"
resolved "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz"
@ -12750,7 +12819,7 @@ batch@0.6.1:
resolved "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz"
integrity sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==
bcrypt-pbkdf@^1.0.0:
bcrypt-pbkdf@^1.0.0, bcrypt-pbkdf@^1.0.2:
version "1.0.2"
resolved "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz"
integrity sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==
@ -13212,6 +13281,11 @@ bufrw@^1.2.1:
hexer "^1.5.0"
xtend "^4.0.0"
buildcheck@~0.0.6:
version "0.0.6"
resolved "https://registry.yarnpkg.com/buildcheck/-/buildcheck-0.0.6.tgz#89aa6e417cfd1e2196e3f8fe915eb709d2fe4238"
integrity sha512-8f9ZJCUXyT1M35Jx7MkBgmBMo3oHTTBIPLiY9xyL0pl3T5RwcPEY8cUHr5LBNfu/fk6c2T4DJZuVM/8ZZT2D2A==
builtin-status-codes@^3.0.0:
version "3.0.0"
resolved "https://registry.npmjs.org/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz"
@ -13262,6 +13336,11 @@ busboy@1.6.0, busboy@^1.6.0:
dependencies:
streamsearch "^1.1.0"
byline@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/byline/-/byline-5.0.0.tgz#741c5216468eadc457b03410118ad77de8c1ddb1"
integrity sha512-s6webAy+R4SR8XVuJWt2V2rGvhnrhxN+9S15GNuTK3wKPOXFF6RNc+8ug2XhH+2s4f+uudG4kUVYmYOQWL2g0Q==
byte-size@8.1.1:
version "8.1.1"
resolved "https://registry.npmjs.org/byte-size/-/byte-size-8.1.1.tgz"
@ -14853,6 +14932,14 @@ cp-file@^7.0.0:
nested-error-stacks "^2.0.0"
p-event "^4.1.0"
cpu-features@~0.0.10:
version "0.0.10"
resolved "https://registry.yarnpkg.com/cpu-features/-/cpu-features-0.0.10.tgz#9aae536db2710c7254d7ed67cb3cbc7d29ad79c5"
integrity sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==
dependencies:
buildcheck "~0.0.6"
nan "^2.19.0"
cpy@^8.1.2:
version "8.1.2"
resolved "https://registry.npmjs.org/cpy/-/cpy-8.1.2.tgz"
@ -15367,6 +15454,13 @@ debug@^3.0.0, debug@^3.1.0, debug@^3.2.7:
dependencies:
ms "^2.1.1"
debug@^4.4.3:
version "4.4.3"
resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a"
integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==
dependencies:
ms "^2.1.3"
debuglog@^1.0.1:
version "1.0.1"
resolved "https://registry.npmjs.org/debuglog/-/debuglog-1.0.1.tgz"
@ -15824,6 +15918,36 @@ dns-packet@^5.2.2:
dependencies:
"@leichtgewicht/ip-codec" "^2.0.1"
docker-compose@^1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/docker-compose/-/docker-compose-1.3.0.tgz#6da4bb9d542b4cce6474aa3146a909eda5d23623"
integrity sha512-7Gevk/5eGD50+eMD+XDnFnOrruFkL0kSd7jEG4cjmqweDSUhB7i0g8is/nBdVpl+Bx338SqIB2GLKm32M+Vs6g==
dependencies:
yaml "^2.2.2"
docker-modem@^5.0.6:
version "5.0.6"
resolved "https://registry.yarnpkg.com/docker-modem/-/docker-modem-5.0.6.tgz#cbe9d86a1fe66d7a072ac7fb99a9fc390a3e8b9a"
integrity sha512-ens7BiayssQz/uAxGzH8zGXCtiV24rRWXdjNha5V4zSOcxmAZsfGVm/PPFbwQdqEkDnhG+SyR9E3zSHUbOKXBQ==
dependencies:
debug "^4.1.1"
readable-stream "^3.5.0"
split-ca "^1.0.1"
ssh2 "^1.15.0"
dockerode@^4.0.8:
version "4.0.9"
resolved "https://registry.yarnpkg.com/dockerode/-/dockerode-4.0.9.tgz#15b32000edad25520be6fafa9ad6bc4529b06be7"
integrity sha512-iND4mcOWhPaCNh54WmK/KoSb35AFqPAUWFMffTQcp52uQt36b5uNwEJTSXntJZBbeGad72Crbi/hvDIv6us/6Q==
dependencies:
"@balena/dockerignore" "^1.0.2"
"@grpc/grpc-js" "^1.11.1"
"@grpc/proto-loader" "^0.7.13"
docker-modem "^5.0.6"
protobufjs "^7.3.2"
tar-fs "^2.1.4"
uuid "^10.0.0"
doctrine@^2.1.0:
version "2.1.0"
resolved "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz"
@ -18393,6 +18517,11 @@ get-port@5.1.1:
resolved "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz"
integrity sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==
get-port@^7.1.0:
version "7.1.0"
resolved "https://registry.yarnpkg.com/get-port/-/get-port-7.1.0.tgz#d5a500ebfc7aa705294ec2b83cc38c5d0e364fec"
integrity sha512-QB9NKEeDg3xxVwCCwJQ9+xycaz6pBB6iQ76wiWMl1927n0Kir6alPiP+yuiICLLU4jpMe08dXfpebuQppFA2zw==
get-proto@^1.0.0, get-proto@^1.0.1:
version "1.0.1"
resolved "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz"
@ -24858,7 +24987,7 @@ mute-stream@1.0.0, mute-stream@^1.0.0, mute-stream@~1.0.0:
resolved "https://registry.npmjs.org/mute-stream/-/mute-stream-1.0.0.tgz"
integrity sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==
nan@^2.12.1:
nan@^2.12.1, nan@^2.19.0, nan@^2.23.0:
version "2.23.0"
resolved "https://registry.npmjs.org/nan/-/nan-2.23.0.tgz"
integrity sha512-1UxuyYGdoQHcGg87Lkqm3FzefucTa0NAiOcuRsDmysep3c1LVCRK2krrUDafMWtjSG04htvAmvg96+SDknOmgQ==
@ -27626,6 +27755,22 @@ propagate@^2.0.0:
resolved "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz"
integrity sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==
proper-lockfile@^4.1.2:
version "4.1.2"
resolved "https://registry.yarnpkg.com/proper-lockfile/-/proper-lockfile-4.1.2.tgz#c8b9de2af6b2f1601067f98e01ac66baa223141f"
integrity sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==
dependencies:
graceful-fs "^4.2.4"
retry "^0.12.0"
signal-exit "^3.0.2"
properties-reader@^2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/properties-reader/-/properties-reader-2.3.0.tgz#f3ab84224c9535a7a36e011ae489a79a13b472b2"
integrity sha512-z597WicA7nDZxK12kZqHr2TcvwNU1GCfA5UwfDY/HDp3hXPoPlb5rlEx9bwGTiJnc0OqbBTkU975jDToth8Gxw==
dependencies:
mkdirp "^1.0.4"
property-expr@^2.0.4:
version "2.0.6"
resolved "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz"
@ -30693,6 +30838,11 @@ spdy@^4.0.2:
select-hose "^2.0.0"
spdy-transport "^3.0.0"
split-ca@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/split-ca/-/split-ca-1.0.1.tgz#6c83aff3692fa61256e0cd197e05e9de157691a6"
integrity sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==
split-string@^3.0.1, split-string@^3.0.2:
version "3.1.0"
resolved "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz"
@ -30748,6 +30898,25 @@ sql-highlight@^6.0.0:
resolved "https://registry.npmjs.org/sql-highlight/-/sql-highlight-6.1.0.tgz"
integrity sha512-ed7OK4e9ywpE7pgRMkMQmZDPKSVdm0oX5IEtZiKnFucSF0zu6c80GZBe38UqHuVhTWJ9xsKgSMjCG2bml86KvA==
ssh-remote-port-forward@^1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/ssh-remote-port-forward/-/ssh-remote-port-forward-1.0.4.tgz#72b0c5df8ec27ca300c75805cc6b266dee07e298"
integrity sha512-x0LV1eVDwjf1gmG7TTnfqIzf+3VPRz7vrNIjX6oYLbeCrf/PeVY6hkT68Mg+q02qXxQhrLjB0jfgvhevoCRmLQ==
dependencies:
"@types/ssh2" "^0.5.48"
ssh2 "^1.4.0"
ssh2@^1.15.0, ssh2@^1.4.0:
version "1.17.0"
resolved "https://registry.yarnpkg.com/ssh2/-/ssh2-1.17.0.tgz#dc686e8e3abdbd4ad95d46fa139615903c12258c"
integrity sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==
dependencies:
asn1 "^0.2.6"
bcrypt-pbkdf "^1.0.2"
optionalDependencies:
cpu-features "~0.0.10"
nan "^2.23.0"
sshpk@^1.14.1, sshpk@^1.7.0:
version "1.18.0"
resolved "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz"
@ -31528,6 +31697,16 @@ tar-fs@^2.0.0:
pump "^3.0.0"
tar-stream "^2.1.4"
tar-fs@^2.1.4:
version "2.1.4"
resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-2.1.4.tgz#800824dbf4ef06ded9afea4acafe71c67c76b930"
integrity sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==
dependencies:
chownr "^1.1.1"
mkdirp-classic "^0.5.2"
pump "^3.0.0"
tar-stream "^2.1.4"
tar-fs@^3.0.4, tar-fs@^3.0.6:
version "3.1.0"
resolved "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.0.tgz"
@ -31539,6 +31718,17 @@ tar-fs@^3.0.4, tar-fs@^3.0.6:
bare-fs "^4.0.1"
bare-path "^3.0.0"
tar-fs@^3.1.1:
version "3.1.1"
resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-3.1.1.tgz#4f164e59fb60f103d472360731e8c6bb4a7fe9ef"
integrity sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==
dependencies:
pump "^3.0.0"
tar-stream "^3.1.5"
optionalDependencies:
bare-fs "^4.0.1"
bare-path "^3.0.0"
tar-stream@^2.1.4, tar-stream@~2.2.0:
version "2.2.0"
resolved "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz"
@ -31723,6 +31913,27 @@ test-exclude@^6.0.0:
glob "^7.1.4"
minimatch "^3.0.4"
testcontainers@^11.7.1:
version "11.7.1"
resolved "https://registry.yarnpkg.com/testcontainers/-/testcontainers-11.7.1.tgz#4ee8595f02b594012fae10433e059608a4aaa565"
integrity sha512-fjut+07G4Avp6Lly/6hQePpUpQFv9ZyQd+7JC5iCDKg+dWa2Sw7fXD3pBrkzslYFfKqGx9M6kyIaLpg9VeMsjw==
dependencies:
"@balena/dockerignore" "^1.0.2"
"@types/dockerode" "^3.3.44"
archiver "^7.0.1"
async-lock "^1.4.1"
byline "^5.0.0"
debug "^4.4.3"
docker-compose "^1.3.0"
dockerode "^4.0.8"
get-port "^7.1.0"
proper-lockfile "^4.1.2"
properties-reader "^2.3.0"
ssh-remote-port-forward "^1.0.4"
tar-fs "^3.1.1"
tmp "^0.2.5"
undici "^7.16.0"
text-decoder@^1.1.0:
version "1.2.3"
resolved "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz"
@ -31919,7 +32130,7 @@ tmp@^0.0.33:
dependencies:
os-tmpdir "~1.0.2"
tmp@~0.2.1:
tmp@^0.2.5, tmp@~0.2.1:
version "0.2.5"
resolved "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz"
integrity sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==
@ -32655,6 +32866,11 @@ undici-types@~7.10.0:
resolved "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz"
integrity sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==
undici@^7.16.0:
version "7.16.0"
resolved "https://registry.yarnpkg.com/undici/-/undici-7.16.0.tgz#cb2a1e957726d458b536e3f076bf51f066901c1a"
integrity sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==
unfetch@^4.2.0:
version "4.2.0"
resolved "https://registry.npmjs.org/unfetch/-/unfetch-4.2.0.tgz"
@ -34311,7 +34527,7 @@ yaml@^1.10.0, yaml@^1.7.2:
resolved "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz"
integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==
yaml@^2.2.1, yaml@^2.7.1:
yaml@^2.2.1, yaml@^2.2.2, yaml@^2.7.1:
version "2.8.1"
resolved "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz"
integrity sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==