diff --git a/apps/backend/package.json b/apps/backend/package.json index 2285e2c..90a6069 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -86,6 +86,9 @@ "transform": { "^.+\\.(t|j)s$": "ts-jest" }, + "moduleNameMapper": { + "^src/(.*)$": "/$1" + }, "collectCoverageFrom": [ "**/*.(t|j)s" ], diff --git a/apps/backend/src/test/factories/account.factory.spec.ts b/apps/backend/src/test/factories/account.factory.spec.ts new file mode 100644 index 0000000..0136964 --- /dev/null +++ b/apps/backend/src/test/factories/account.factory.spec.ts @@ -0,0 +1,102 @@ +import { createTestAccount, createTestAccounts } from './account.factory'; + +describe('Account Factory', () => { + describe('createTestAccount', () => { + it('should create account with default values', () => { + const account = createTestAccount(); + + expect(account.id).toBeDefined(); + expect(account.name).toMatch(/^Test Account [a-f0-9-]+$/); + expect(account.description).toBe('Test account description'); + }); + + it('should accept custom id', () => { + const account = createTestAccount({ id: 'custom-account-id' }); + + expect(account.id).toBe('custom-account-id'); + }); + + it('should accept custom name', () => { + const account = createTestAccount({ name: 'My Custom Account' }); + + expect(account.name).toBe('My Custom Account'); + }); + + it('should accept custom description', () => { + const account = createTestAccount({ description: 'Custom description' }); + + expect(account.description).toBe('Custom description'); + }); + + it('should accept all options at once', () => { + const account = createTestAccount({ + id: 'acc-123', + name: 'Full Account', + description: 'Full description', + }); + + expect(account.id).toBe('acc-123'); + expect(account.name).toBe('Full Account'); + expect(account.description).toBe('Full description'); + }); + + it('should create unique IDs for multiple accounts', () => { + const account1 = createTestAccount(); + const account2 = createTestAccount(); + + expect(account1.id).not.toBe(account2.id); + }); + + it('should create unique names for multiple accounts', () => { + const account1 = createTestAccount(); + const account2 = createTestAccount(); + + expect(account1.name).not.toBe(account2.name); + }); + }); + + describe('createTestAccounts', () => { + it('should create specified number of accounts', () => { + const accounts = createTestAccounts(5); + + expect(accounts).toHaveLength(5); + }); + + it('should create zero accounts when count is 0', () => { + const accounts = createTestAccounts(0); + + expect(accounts).toHaveLength(0); + }); + + it('should create accounts with sequential names', () => { + const accounts = createTestAccounts(3); + + expect(accounts[0].name).toBe('Test Account 1'); + expect(accounts[1].name).toBe('Test Account 2'); + expect(accounts[2].name).toBe('Test Account 3'); + }); + + it('should create accounts with unique IDs', () => { + const accounts = createTestAccounts(3); + const ids = accounts.map((a) => a.id); + const uniqueIds = new Set(ids); + + expect(uniqueIds.size).toBe(3); + }); + + it('should create accounts with default description', () => { + const accounts = createTestAccounts(2); + + accounts.forEach((account) => { + expect(account.description).toBe('Test account description'); + }); + }); + + it('should handle large count', () => { + const accounts = createTestAccounts(100); + + expect(accounts).toHaveLength(100); + expect(accounts[99].name).toBe('Test Account 100'); + }); + }); +}); diff --git a/apps/backend/src/test/factories/account.factory.ts b/apps/backend/src/test/factories/account.factory.ts new file mode 100644 index 0000000..6995a9c --- /dev/null +++ b/apps/backend/src/test/factories/account.factory.ts @@ -0,0 +1,34 @@ +import { Account } from '../../entities/account.entity'; +import { v4 as uuid } from 'uuid'; + +export interface CreateTestAccountOptions { + id?: string; + name?: string; + description?: string; +} + +/** + * Factory for creating test Account entities. + */ +export function createTestAccount( + options: CreateTestAccountOptions = {}, +): Account { + const account = new Account(); + + account.id = options.id ?? uuid(); + account.name = options.name ?? `Test Account ${uuid().slice(0, 8)}`; + account.description = options.description ?? 'Test account description'; + + return account; +} + +/** + * Create multiple test accounts + */ +export function createTestAccounts(count: number): Account[] { + return Array.from({ length: count }, (_, index) => + createTestAccount({ + name: `Test Account ${index + 1}`, + }), + ); +} diff --git a/apps/backend/src/test/factories/index.ts b/apps/backend/src/test/factories/index.ts new file mode 100644 index 0000000..94c8946 --- /dev/null +++ b/apps/backend/src/test/factories/index.ts @@ -0,0 +1,2 @@ +export * from './user.factory'; +export * from './account.factory'; diff --git a/apps/backend/src/test/factories/user.factory.spec.ts b/apps/backend/src/test/factories/user.factory.spec.ts new file mode 100644 index 0000000..a4af308 --- /dev/null +++ b/apps/backend/src/test/factories/user.factory.spec.ts @@ -0,0 +1,210 @@ +import { + createTestUser, + createTestUserWithProvider, + createTestSuperAdmin, + createTestInvitedUser, + CreateTestUserOptions, +} from './user.factory'; + +describe('User Factory', () => { + describe('createTestUser', () => { + it('should create user with default values', () => { + const user = createTestUser(); + + expect(user.id).toBeDefined(); + expect(user.email).toMatch(/^test-[a-f0-9-]+@example\.com$/); + expect(user.name).toBe('Test User'); + expect(user.status).toBe('accepted'); + expect(user.providerIds).toEqual([]); + expect(user.isSuperAdmin).toBe(false); + expect(user.profileImage).toBeNull(); + }); + + it('should accept custom id', () => { + const user = createTestUser({ id: 'custom-id-123' }); + + expect(user.id).toBe('custom-id-123'); + }); + + it('should accept custom email', () => { + const user = createTestUser({ email: 'custom@example.com' }); + + expect(user.email).toBe('custom@example.com'); + }); + + it('should use default email when null is passed (nullish coalescing behavior)', () => { + const user = createTestUser({ email: null }); + + // Note: nullish coalescing (??) returns default for null + expect(user.email).toMatch(/^test-[a-f0-9-]+@example\.com$/); + }); + + it('should accept custom name', () => { + const user = createTestUser({ name: 'John Doe' }); + + expect(user.name).toBe('John Doe'); + }); + + it('should accept custom status', () => { + const user = createTestUser({ status: 'pending' }); + + expect(user.status).toBe('pending'); + }); + + it('should accept custom providerIds', () => { + const providerIds = ['google|123', 'auth0|456']; + const user = createTestUser({ providerIds }); + + expect(user.providerIds).toEqual(providerIds); + }); + + it('should accept isSuperAdmin flag', () => { + const user = createTestUser({ isSuperAdmin: true }); + + expect(user.isSuperAdmin).toBe(true); + }); + + it('should accept custom profileImage', () => { + const user = createTestUser({ profileImage: 'https://example.com/photo.jpg' }); + + expect(user.profileImage).toBe('https://example.com/photo.jpg'); + }); + + it('should accept multiple options at once', () => { + const options: CreateTestUserOptions = { + id: 'user-123', + email: 'test@test.com', + name: 'Full Name', + status: 'invited', + providerIds: ['google|abc'], + isSuperAdmin: true, + profileImage: 'image.jpg', + }; + + const user = createTestUser(options); + + expect(user.id).toBe('user-123'); + expect(user.email).toBe('test@test.com'); + expect(user.name).toBe('Full Name'); + expect(user.status).toBe('invited'); + expect(user.providerIds).toEqual(['google|abc']); + expect(user.isSuperAdmin).toBe(true); + expect(user.profileImage).toBe('image.jpg'); + }); + + it('should create unique IDs for multiple users', () => { + const user1 = createTestUser(); + const user2 = createTestUser(); + + expect(user1.id).not.toBe(user2.id); + }); + + it('should create unique emails for multiple users', () => { + const user1 = createTestUser(); + const user2 = createTestUser(); + + expect(user1.email).not.toBe(user2.email); + }); + }); + + describe('createTestUserWithProvider', () => { + it('should create user with google provider', () => { + const user = createTestUserWithProvider('google', '123456789'); + + expect(user.providerIds).toContain('google|123456789'); + }); + + it('should create user with auth0 provider', () => { + const user = createTestUserWithProvider('auth0', 'user_abc'); + + expect(user.providerIds).toContain('auth0|user_abc'); + }); + + it('should preserve additional options', () => { + const user = createTestUserWithProvider('google', '123', { + name: 'Custom Name', + isSuperAdmin: true, + }); + + expect(user.providerIds).toContain('google|123'); + expect(user.name).toBe('Custom Name'); + expect(user.isSuperAdmin).toBe(true); + }); + + it('should prepend new provider to existing providerIds', () => { + const user = createTestUserWithProvider('google', 'new-id', { + providerIds: ['auth0|existing'], + }); + + expect(user.providerIds).toHaveLength(2); + expect(user.providerIds[0]).toBe('google|new-id'); + expect(user.providerIds[1]).toBe('auth0|existing'); + }); + }); + + describe('createTestSuperAdmin', () => { + it('should create user with isSuperAdmin true', () => { + const admin = createTestSuperAdmin(); + + expect(admin.isSuperAdmin).toBe(true); + }); + + it('should use admin@example.com as default email', () => { + const admin = createTestSuperAdmin(); + + expect(admin.email).toBe('admin@example.com'); + }); + + it('should use Super Admin as default name', () => { + const admin = createTestSuperAdmin(); + + expect(admin.name).toBe('Super Admin'); + }); + + it('should allow overriding email', () => { + const admin = createTestSuperAdmin({ email: 'custom-admin@example.com' }); + + expect(admin.email).toBe('custom-admin@example.com'); + }); + + it('should allow overriding name', () => { + const admin = createTestSuperAdmin({ name: 'Custom Admin' }); + + expect(admin.name).toBe('Custom Admin'); + }); + + it('should preserve other default values', () => { + const admin = createTestSuperAdmin(); + + expect(admin.status).toBe('accepted'); + expect(admin.providerIds).toEqual([]); + expect(admin.profileImage).toBeNull(); + }); + }); + + describe('createTestInvitedUser', () => { + it('should create user with status invited', () => { + const user = createTestInvitedUser(); + + expect(user.status).toBe('invited'); + }); + + it('should use default values for other fields', () => { + const user = createTestInvitedUser(); + + expect(user.name).toBe('Test User'); + expect(user.isSuperAdmin).toBe(false); + }); + + it('should allow overriding other options', () => { + const user = createTestInvitedUser({ + name: 'Pending User', + email: 'pending@example.com', + }); + + expect(user.status).toBe('invited'); + expect(user.name).toBe('Pending User'); + expect(user.email).toBe('pending@example.com'); + }); + }); +}); diff --git a/apps/backend/src/test/factories/user.factory.ts b/apps/backend/src/test/factories/user.factory.ts new file mode 100644 index 0000000..cb62b77 --- /dev/null +++ b/apps/backend/src/test/factories/user.factory.ts @@ -0,0 +1,71 @@ +import { User } from '../../entities/user.entity'; +import { v4 as uuid } from 'uuid'; + +export interface CreateTestUserOptions { + id?: string; + email?: string | null; + name?: string; + status?: string; + providerIds?: string[]; + isSuperAdmin?: boolean; + profileImage?: string | null; +} + +/** + * Factory for creating test User entities. + * Use this in tests to create consistent, valid user objects. + */ +export function createTestUser(options: CreateTestUserOptions = {}): User { + const user = new User(); + + user.id = options.id ?? uuid(); + user.email = options.email ?? `test-${uuid().slice(0, 8)}@example.com`; + user.name = options.name ?? 'Test User'; + user.status = options.status ?? 'accepted'; + user.providerIds = options.providerIds ?? []; + user.isSuperAdmin = options.isSuperAdmin ?? false; + user.profileImage = (options.profileImage ?? null) as string; + + return user; +} + +/** + * Create a test user with provider IDs + */ +export function createTestUserWithProvider( + provider: 'google' | 'auth0', + providerId: string, + options: CreateTestUserOptions = {}, +): User { + const fullProviderId = `${provider}|${providerId}`; + return createTestUser({ + ...options, + providerIds: [fullProviderId, ...(options.providerIds ?? [])], + }); +} + +/** + * Create a super admin test user + */ +export function createTestSuperAdmin( + options: CreateTestUserOptions = {}, +): User { + return createTestUser({ + ...options, + isSuperAdmin: true, + email: options.email ?? 'admin@example.com', + name: options.name ?? 'Super Admin', + }); +} + +/** + * Create an invited (pending) test user + */ +export function createTestInvitedUser( + options: CreateTestUserOptions = {}, +): User { + return createTestUser({ + ...options, + status: 'invited', + }); +} diff --git a/apps/backend/src/test/helpers/auth.helper.spec.ts b/apps/backend/src/test/helpers/auth.helper.spec.ts new file mode 100644 index 0000000..c184c8e --- /dev/null +++ b/apps/backend/src/test/helpers/auth.helper.spec.ts @@ -0,0 +1,241 @@ +import * as jwt from 'jsonwebtoken'; +import { + createTestJwt, + createAuthHeader, + createUserAuthHeader, + createAdminJwt, + createSuperAdminJwt, + decodeTestJwt, + getTestJwtSecret, +} from './auth.helper'; +import { createTestUser } from '../factories/user.factory'; + +describe('Auth Helper', () => { + describe('createTestJwt', () => { + it('should create valid JWT token', () => { + const token = createTestJwt({}); + + expect(token).toBeDefined(); + expect(token.split('.')).toHaveLength(3); + }); + + it('should include default claims', () => { + const token = createTestJwt({}); + const decoded = jwt.decode(token) as Record; + + expect(decoded.sub).toBe('test-user-id'); + expect(decoded.email).toBe('test@example.com'); + expect(decoded.roles).toEqual(['user']); + expect(decoded.iss).toBe('https://test-issuer.example.com/'); + expect(decoded.aud).toEqual(['test-audience']); + }); + + it('should include iat and exp claims', () => { + const before = Math.floor(Date.now() / 1000); + const token = createTestJwt({}); + const decoded = jwt.decode(token) as Record; + const after = Math.floor(Date.now() / 1000); + + expect(decoded.iat).toBeGreaterThanOrEqual(before); + expect(decoded.iat).toBeLessThanOrEqual(after); + expect(decoded.exp).toBe(decoded.iat + 3600); + }); + + it('should allow overriding sub claim', () => { + const token = createTestJwt({ sub: 'custom-user-123' }); + const decoded = jwt.decode(token) as Record; + + expect(decoded.sub).toBe('custom-user-123'); + }); + + it('should allow overriding email claim', () => { + const token = createTestJwt({ email: 'custom@example.com' }); + const decoded = jwt.decode(token) as Record; + + expect(decoded.email).toBe('custom@example.com'); + }); + + it('should allow overriding roles', () => { + const token = createTestJwt({ roles: ['admin', 'manager'] }); + const decoded = jwt.decode(token) as Record; + + expect(decoded.roles).toEqual(['admin', 'manager']); + }); + + it('should allow setting custom accountId', () => { + const token = createTestJwt({ accountId: 'account-456' }); + const decoded = jwt.decode(token) as Record; + + expect(decoded.accountId).toBe('account-456'); + }); + + it('should include https://bri.us/roles claim', () => { + const token = createTestJwt({}); + const decoded = jwt.decode(token) as Record; + + expect(decoded['https://bri.us/roles']).toEqual(['user']); + }); + + it('should be verifiable with test secret', () => { + const token = createTestJwt({}); + + expect(() => + jwt.verify(token, getTestJwtSecret()), + ).not.toThrow(); + }); + }); + + describe('createAuthHeader', () => { + it('should create Bearer token format', () => { + const header = createAuthHeader('my-token'); + + expect(header).toBe('Bearer my-token'); + }); + + it('should work with actual JWT token', () => { + const token = createTestJwt({}); + const header = createAuthHeader(token); + + expect(header).toMatch(/^Bearer [a-zA-Z0-9._-]+$/); + }); + }); + + describe('createUserAuthHeader', () => { + it('should create auth header for user', () => { + const user = createTestUser({ + id: 'user-123', + email: 'user@test.com', + }); + + const header = createUserAuthHeader(user); + + expect(header).toMatch(/^Bearer /); + + const token = header.replace('Bearer ', ''); + const decoded = jwt.decode(token) as Record; + + expect(decoded.sub).toBe('user-123'); + expect(decoded.email).toBe('user@test.com'); + }); + + it('should use user email even when null was passed to factory (factory uses default)', () => { + // Note: createTestUser uses nullish coalescing, so null becomes default email + const user = createTestUser({ email: null }); + const header = createUserAuthHeader(user); + + const token = header.replace('Bearer ', ''); + const decoded = jwt.decode(token) as Record; + + // The factory converts null to default email, which then gets used here + expect(decoded.email).toMatch(/^test-[a-f0-9-]+@example\.com$/); + }); + + it('should include custom roles', () => { + const user = createTestUser(); + const header = createUserAuthHeader(user, { roles: ['admin', 'editor'] }); + + const token = header.replace('Bearer ', ''); + const decoded = jwt.decode(token) as Record; + + expect(decoded.roles).toEqual(['admin', 'editor']); + expect(decoded['https://bri.us/roles']).toEqual(['admin', 'editor']); + }); + + it('should include custom permissions', () => { + const user = createTestUser(); + const header = createUserAuthHeader(user, { + permissions: ['read:users', 'write:users'], + }); + + const token = header.replace('Bearer ', ''); + const decoded = jwt.decode(token) as Record; + + expect(decoded.permissions).toEqual(['read:users', 'write:users']); + }); + }); + + describe('createAdminJwt', () => { + it('should create token with admin role', () => { + const token = createAdminJwt(); + const decoded = jwt.decode(token) as Record; + + expect(decoded.roles).toEqual(['admin']); + expect(decoded['https://bri.us/roles']).toEqual(['admin']); + }); + + it('should allow additional claims', () => { + const token = createAdminJwt({ + sub: 'admin-user-123', + email: 'admin@company.com', + }); + const decoded = jwt.decode(token) as Record; + + expect(decoded.sub).toBe('admin-user-123'); + expect(decoded.email).toBe('admin@company.com'); + expect(decoded.roles).toEqual(['admin']); + }); + }); + + describe('createSuperAdminJwt', () => { + it('should create token with super_admin role', () => { + const token = createSuperAdminJwt(); + const decoded = jwt.decode(token) as Record; + + expect(decoded.roles).toEqual(['super_admin']); + expect(decoded['https://bri.us/roles']).toEqual(['super_admin']); + }); + + it('should allow additional claims', () => { + const token = createSuperAdminJwt({ + sub: 'super-admin-123', + }); + const decoded = jwt.decode(token) as Record; + + expect(decoded.sub).toBe('super-admin-123'); + expect(decoded.roles).toEqual(['super_admin']); + }); + }); + + describe('decodeTestJwt', () => { + it('should decode JWT payload', () => { + const token = createTestJwt({ + sub: 'decode-test', + email: 'decode@test.com', + }); + + const decoded = decodeTestJwt(token); + + expect(decoded.sub).toBe('decode-test'); + expect(decoded.email).toBe('decode@test.com'); + }); + + it('should return all payload claims', () => { + const token = createTestJwt({ + roles: ['viewer'], + permissions: ['read'], + }); + + const decoded = decodeTestJwt(token); + + expect(decoded.roles).toEqual(['viewer']); + expect(decoded.permissions).toEqual(['read']); + }); + }); + + describe('getTestJwtSecret', () => { + it('should return test secret', () => { + const secret = getTestJwtSecret(); + + expect(secret).toBeDefined(); + expect(typeof secret).toBe('string'); + expect(secret.length).toBeGreaterThan(0); + }); + + it('should return consistent secret', () => { + const secret1 = getTestJwtSecret(); + const secret2 = getTestJwtSecret(); + + expect(secret1).toBe(secret2); + }); + }); +}); diff --git a/apps/backend/src/test/helpers/auth.helper.ts b/apps/backend/src/test/helpers/auth.helper.ts new file mode 100644 index 0000000..f2d26c7 --- /dev/null +++ b/apps/backend/src/test/helpers/auth.helper.ts @@ -0,0 +1,110 @@ +import * as jwt from 'jsonwebtoken'; +import { User } from '../../entities/user.entity'; + +/** + * Auth helper utilities for testing. + * Provides JWT token creation and auth header generation. + */ + +export interface TestJwtPayload { + sub: string; + email: string; + roles?: string[]; + permissions?: string[]; + identities?: string[]; + accountId?: string; + iss?: string; + aud?: string | string[]; + 'https://bri.us/roles'?: string[]; +} + +// Test secret for JWT signing (only for tests) +const TEST_JWT_SECRET = 'test-jwt-secret-key-for-testing-only'; + +/** + * Create a test JWT token with custom claims + */ +export function createTestJwt(claims: Partial): string { + const defaultClaims: TestJwtPayload = { + sub: 'test-user-id', + email: 'test@example.com', + roles: ['user'], + permissions: [], + identities: [], + iss: 'https://test-issuer.example.com/', + aud: ['test-audience'], + 'https://bri.us/roles': ['user'], + }; + + const payload = { + ...defaultClaims, + ...claims, + iat: Math.floor(Date.now() / 1000), + exp: Math.floor(Date.now() / 1000) + 3600, // 1 hour expiry + }; + + return jwt.sign(payload, TEST_JWT_SECRET); +} + +/** + * Create an Authorization header value from a JWT token + */ +export function createAuthHeader(token: string): string { + return `Bearer ${token}`; +} + +/** + * Create a test JWT and Authorization header for a user + */ +export function createUserAuthHeader( + user: User, + options: { roles?: string[]; permissions?: string[]; accountId?: string } = {}, +): string { + const token = createTestJwt({ + sub: user.id, + email: user.email || 'test@example.com', + roles: options.roles ?? ['user'], + permissions: options.permissions ?? [], + 'https://bri.us/roles': options.roles ?? ['user'], + }); + + return createAuthHeader(token); +} + +/** + * Create a test JWT for an admin user + */ +export function createAdminJwt(claims: Partial = {}): string { + return createTestJwt({ + roles: ['admin'], + 'https://bri.us/roles': ['admin'], + ...claims, + }); +} + +/** + * Create a test JWT for a super admin user + */ +export function createSuperAdminJwt( + claims: Partial = {}, +): string { + return createTestJwt({ + roles: ['super_admin'], + 'https://bri.us/roles': ['super_admin'], + ...claims, + }); +} + +/** + * Decode a JWT token without verification (for testing assertions) + */ +export function decodeTestJwt(token: string): TestJwtPayload { + return jwt.decode(token) as TestJwtPayload; +} + +/** + * Get the test JWT secret (for configuring test modules) + */ +export function getTestJwtSecret(): string { + return TEST_JWT_SECRET; +} diff --git a/apps/backend/src/test/helpers/database.helper.spec.ts b/apps/backend/src/test/helpers/database.helper.spec.ts new file mode 100644 index 0000000..9c1f3c9 --- /dev/null +++ b/apps/backend/src/test/helpers/database.helper.spec.ts @@ -0,0 +1,250 @@ +import { DataSource, QueryRunner } from 'typeorm'; +import { + createTestDataSource, + setupTestDatabase, + teardownTestDatabase, + clearTables, + clearAllTables, + getDataSourceFromApp, + resetDatabase, +} from './database.helper'; +import { INestApplication } from '@nestjs/common'; + +describe('Database Helper', () => { + describe('createTestDataSource', () => { + it('should create SQLite in-memory data source', () => { + const dataSource = createTestDataSource(); + + expect(dataSource).toBeInstanceOf(DataSource); + expect(dataSource.options.type).toBe('sqlite'); + expect(dataSource.options.database).toBe(':memory:'); + }); + + it('should enable synchronize for test database', () => { + const dataSource = createTestDataSource(); + + expect(dataSource.options.synchronize).toBe(true); + }); + + it('should enable dropSchema for test database', () => { + const dataSource = createTestDataSource(); + + expect(dataSource.options.dropSchema).toBe(true); + }); + + it('should disable logging by default', () => { + const dataSource = createTestDataSource(); + + expect(dataSource.options.logging).toBe(false); + }); + + it('should include entity paths', () => { + const dataSource = createTestDataSource(); + + expect(dataSource.options.entities).toContain('src/**/*.entity.ts'); + }); + + it('should create unique data source instances', () => { + const ds1 = createTestDataSource(); + const ds2 = createTestDataSource(); + + expect(ds1).not.toBe(ds2); + }); + }); + + describe('setupTestDatabase', () => { + it('should initialize uninitialized data source', async () => { + const mockDataSource = { + isInitialized: false, + initialize: jest.fn().mockResolvedValue(undefined), + } as unknown as DataSource; + + await setupTestDatabase(mockDataSource); + + expect(mockDataSource.initialize).toHaveBeenCalled(); + }); + + it('should not initialize already initialized data source', async () => { + const mockDataSource = { + isInitialized: true, + initialize: jest.fn(), + } as unknown as DataSource; + + await setupTestDatabase(mockDataSource); + + expect(mockDataSource.initialize).not.toHaveBeenCalled(); + }); + }); + + describe('teardownTestDatabase', () => { + it('should destroy initialized data source', async () => { + const mockDataSource = { + isInitialized: true, + destroy: jest.fn().mockResolvedValue(undefined), + } as unknown as DataSource; + + await teardownTestDatabase(mockDataSource); + + expect(mockDataSource.destroy).toHaveBeenCalled(); + }); + + it('should not destroy uninitialized data source', async () => { + const mockDataSource = { + isInitialized: false, + destroy: jest.fn(), + } as unknown as DataSource; + + await teardownTestDatabase(mockDataSource); + + expect(mockDataSource.destroy).not.toHaveBeenCalled(); + }); + }); + + describe('clearTables', () => { + it('should execute DELETE queries for specified tables', async () => { + const mockQueryRunner: Partial = { + connect: jest.fn().mockResolvedValue(undefined), + startTransaction: jest.fn().mockResolvedValue(undefined), + query: jest.fn().mockResolvedValue(undefined), + commitTransaction: jest.fn().mockResolvedValue(undefined), + rollbackTransaction: jest.fn(), + release: jest.fn().mockResolvedValue(undefined), + }; + + const mockDataSource = { + createQueryRunner: jest.fn().mockReturnValue(mockQueryRunner), + } as unknown as DataSource; + + await clearTables(mockDataSource, ['users', 'accounts']); + + expect(mockQueryRunner.query).toHaveBeenCalledWith('PRAGMA foreign_keys = OFF'); + expect(mockQueryRunner.query).toHaveBeenCalledWith('DELETE FROM "users"'); + expect(mockQueryRunner.query).toHaveBeenCalledWith('DELETE FROM "accounts"'); + expect(mockQueryRunner.query).toHaveBeenCalledWith('PRAGMA foreign_keys = ON'); + expect(mockQueryRunner.commitTransaction).toHaveBeenCalled(); + expect(mockQueryRunner.release).toHaveBeenCalled(); + }); + + it('should rollback on error', async () => { + const mockQueryRunner: Partial = { + connect: jest.fn().mockResolvedValue(undefined), + startTransaction: jest.fn().mockResolvedValue(undefined), + query: jest.fn().mockRejectedValue(new Error('DB error')), + commitTransaction: jest.fn(), + rollbackTransaction: jest.fn().mockResolvedValue(undefined), + release: jest.fn().mockResolvedValue(undefined), + }; + + const mockDataSource = { + createQueryRunner: jest.fn().mockReturnValue(mockQueryRunner), + } as unknown as DataSource; + + await expect(clearTables(mockDataSource, ['users'])).rejects.toThrow('DB error'); + expect(mockQueryRunner.rollbackTransaction).toHaveBeenCalled(); + expect(mockQueryRunner.release).toHaveBeenCalled(); + }); + + it('should handle empty table list', async () => { + const mockQueryRunner: Partial = { + connect: jest.fn().mockResolvedValue(undefined), + startTransaction: jest.fn().mockResolvedValue(undefined), + query: jest.fn().mockResolvedValue(undefined), + commitTransaction: jest.fn().mockResolvedValue(undefined), + release: jest.fn().mockResolvedValue(undefined), + }; + + const mockDataSource = { + createQueryRunner: jest.fn().mockReturnValue(mockQueryRunner), + } as unknown as DataSource; + + await clearTables(mockDataSource, []); + + // Should still disable/enable foreign keys but no DELETE queries + expect(mockQueryRunner.query).toHaveBeenCalledWith('PRAGMA foreign_keys = OFF'); + expect(mockQueryRunner.query).toHaveBeenCalledWith('PRAGMA foreign_keys = ON'); + expect(mockQueryRunner.query).toHaveBeenCalledTimes(2); + }); + }); + + describe('clearAllTables', () => { + it('should clear all entity tables', async () => { + const mockQueryRunner: Partial = { + connect: jest.fn().mockResolvedValue(undefined), + startTransaction: jest.fn().mockResolvedValue(undefined), + query: jest.fn().mockResolvedValue(undefined), + commitTransaction: jest.fn().mockResolvedValue(undefined), + release: jest.fn().mockResolvedValue(undefined), + }; + + const mockDataSource = { + createQueryRunner: jest.fn().mockReturnValue(mockQueryRunner), + entityMetadatas: [ + { tableName: 'users' }, + { tableName: 'accounts' }, + { tableName: 'audit_logs' }, + ], + } as unknown as DataSource; + + await clearAllTables(mockDataSource); + + expect(mockQueryRunner.query).toHaveBeenCalledWith('DELETE FROM "users"'); + expect(mockQueryRunner.query).toHaveBeenCalledWith('DELETE FROM "accounts"'); + expect(mockQueryRunner.query).toHaveBeenCalledWith('DELETE FROM "audit_logs"'); + }); + + it('should handle empty entity list', async () => { + const mockQueryRunner: Partial = { + connect: jest.fn().mockResolvedValue(undefined), + startTransaction: jest.fn().mockResolvedValue(undefined), + query: jest.fn().mockResolvedValue(undefined), + commitTransaction: jest.fn().mockResolvedValue(undefined), + release: jest.fn().mockResolvedValue(undefined), + }; + + const mockDataSource = { + createQueryRunner: jest.fn().mockReturnValue(mockQueryRunner), + entityMetadatas: [], + } as unknown as DataSource; + + await clearAllTables(mockDataSource); + + // Should only have foreign key toggle calls + expect(mockQueryRunner.query).toHaveBeenCalledTimes(2); + }); + }); + + describe('getDataSourceFromApp', () => { + it('should get DataSource from app', () => { + const mockDataSource = {} as DataSource; + const mockApp = { + get: jest.fn().mockReturnValue(mockDataSource), + } as unknown as INestApplication; + + const result = getDataSourceFromApp(mockApp); + + expect(mockApp.get).toHaveBeenCalledWith(DataSource); + expect(result).toBe(mockDataSource); + }); + }); + + describe('resetDatabase', () => { + it('should call clearAllTables', async () => { + const mockQueryRunner: Partial = { + connect: jest.fn().mockResolvedValue(undefined), + startTransaction: jest.fn().mockResolvedValue(undefined), + query: jest.fn().mockResolvedValue(undefined), + commitTransaction: jest.fn().mockResolvedValue(undefined), + release: jest.fn().mockResolvedValue(undefined), + }; + + const mockDataSource = { + createQueryRunner: jest.fn().mockReturnValue(mockQueryRunner), + entityMetadatas: [{ tableName: 'test' }], + } as unknown as DataSource; + + await resetDatabase(mockDataSource); + + expect(mockQueryRunner.query).toHaveBeenCalledWith('DELETE FROM "test"'); + }); + }); +}); diff --git a/apps/backend/src/test/helpers/database.helper.ts b/apps/backend/src/test/helpers/database.helper.ts new file mode 100644 index 0000000..ea2ec8c --- /dev/null +++ b/apps/backend/src/test/helpers/database.helper.ts @@ -0,0 +1,96 @@ +import { DataSource } from 'typeorm'; +import { INestApplication } from '@nestjs/common'; + +/** + * Database helper utilities for testing. + * Provides setup, teardown, and cleanup operations for test databases. + */ + +/** + * Create a test-specific DataSource configuration + */ +export function createTestDataSource(): DataSource { + return new DataSource({ + type: 'sqlite', + database: ':memory:', + synchronize: true, + dropSchema: true, + entities: ['src/**/*.entity.ts'], + logging: false, + }); +} + +/** + * Setup test database connection + */ +export async function setupTestDatabase(dataSource: DataSource): Promise { + if (!dataSource.isInitialized) { + await dataSource.initialize(); + } +} + +/** + * Teardown test database connection + */ +export async function teardownTestDatabase( + dataSource: DataSource, +): Promise { + if (dataSource.isInitialized) { + await dataSource.destroy(); + } +} + +/** + * Clear all data from specified tables + */ +export async function clearTables( + dataSource: DataSource, + tables: string[], +): Promise { + const queryRunner = dataSource.createQueryRunner(); + + try { + await queryRunner.connect(); + await queryRunner.startTransaction(); + + // Disable foreign key checks for SQLite + await queryRunner.query('PRAGMA foreign_keys = OFF'); + + for (const table of tables) { + await queryRunner.query(`DELETE FROM "${table}"`); + } + + // Re-enable foreign key checks + await queryRunner.query('PRAGMA foreign_keys = ON'); + + await queryRunner.commitTransaction(); + } catch (error) { + await queryRunner.rollbackTransaction(); + throw error; + } finally { + await queryRunner.release(); + } +} + +/** + * Clear all tables in the database + */ +export async function clearAllTables(dataSource: DataSource): Promise { + const entities = dataSource.entityMetadatas; + const tableNames = entities.map((entity) => entity.tableName); + await clearTables(dataSource, tableNames); +} + +/** + * Get DataSource from NestJS application + */ +export function getDataSourceFromApp(app: INestApplication): DataSource { + return app.get(DataSource); +} + +/** + * Reset database to clean state (useful between tests) + */ +export async function resetDatabase(dataSource: DataSource): Promise { + await clearAllTables(dataSource); +} diff --git a/apps/backend/src/test/helpers/index.ts b/apps/backend/src/test/helpers/index.ts new file mode 100644 index 0000000..9fb4b4a --- /dev/null +++ b/apps/backend/src/test/helpers/index.ts @@ -0,0 +1,3 @@ +export * from './database.helper'; +export * from './auth.helper'; +export * from './test-app.helper'; diff --git a/apps/backend/src/test/helpers/test-app.helper.ts b/apps/backend/src/test/helpers/test-app.helper.ts new file mode 100644 index 0000000..67723a4 --- /dev/null +++ b/apps/backend/src/test/helpers/test-app.helper.ts @@ -0,0 +1,104 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ConfigModule } from '@nestjs/config'; +import { ClsModule } from 'nestjs-cls'; +import { getTestJwtSecret } from './auth.helper'; + +/** + * Test application helper utilities. + * Provides utilities for creating and configuring test NestJS applications. + */ + +export interface TestAppOptions { + imports?: any[]; + providers?: any[]; + controllers?: any[]; +} + +/** + * Create a test module with common test configuration + */ +export async function createTestModule( + options: TestAppOptions = {}, +): Promise { + const moduleBuilder = Test.createTestingModule({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + envFilePath: '.env.test', + load: [ + () => ({ + JWT_SECRET: getTestJwtSecret(), + JWKS_URI: 'https://test-issuer.example.com/.well-known/jwks.json', + IDP_ISSUER: 'https://test-issuer.example.com/', + IDP_AUDIENCE: 'test-audience', + AUTH0_DOMAIN: 'test.auth0.com', + AUTH0_CLIENT_ID: 'test-client-id', + AUTH0_CLIENT_SECRET: 'test-client-secret', + AUTH0_ROLES_NAME: 'https://bri.us/roles', + }), + ], + }), + TypeOrmModule.forRoot({ + type: 'sqlite', + database: ':memory:', + synchronize: true, + dropSchema: true, + entities: ['src/**/*.entity.ts'], + logging: false, + }), + ClsModule.forRoot({ + global: true, + middleware: { + mount: false, + }, + }), + ...(options.imports ?? []), + ], + providers: [...(options.providers ?? [])], + controllers: [...(options.controllers ?? [])], + }); + + return moduleBuilder.compile(); +} + +/** + * Create a test NestJS application with common configuration + */ +export async function createTestApp( + options: TestAppOptions = {}, +): Promise { + const moduleFixture = await createTestModule(options); + const app = moduleFixture.createNestApplication(); + + // Apply common pipes + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ); + + await app.init(); + return app; +} + +/** + * Close test application and cleanup + */ +export async function closeTestApp(app: INestApplication): Promise { + await app.close(); +} + +/** + * Create test application with specific modules for integration testing + */ +export async function createIntegrationTestApp( + moduleImports: any[], +): Promise { + return createTestApp({ + imports: moduleImports, + }); +} diff --git a/apps/backend/src/test/index.ts b/apps/backend/src/test/index.ts new file mode 100644 index 0000000..e044744 --- /dev/null +++ b/apps/backend/src/test/index.ts @@ -0,0 +1,2 @@ +export * from './factories'; +export * from './helpers'; diff --git a/apps/backend/test/users.e2e-spec.ts b/apps/backend/test/users.e2e-spec.ts new file mode 100644 index 0000000..64bf147 --- /dev/null +++ b/apps/backend/test/users.e2e-spec.ts @@ -0,0 +1,147 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import * as request from 'supertest'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ConfigModule } from '@nestjs/config'; +import { ClsModule } from 'nestjs-cls'; +import { UsersModule } from '../src/modules/users/users.module'; +import { AccountsModule } from '../src/modules/accounts/accounts.module'; +import { AuthModule } from '../src/auth/auth.module'; +import { + createTestUser, + createTestAccount, + createTestJwt, + createAuthHeader, +} from '../src/test'; +import { User } from '../src/entities/user.entity'; +import { Account } from '../src/entities/account.entity'; +import { UserAccount } from '../src/entities/user-accounts.entity'; +import { DataSource } from 'typeorm'; + +describe('Users Module (e2e)', () => { + let app: INestApplication; + let dataSource: DataSource; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + load: [ + () => ({ + JWKS_URI: + 'https://test-issuer.example.com/.well-known/jwks.json', + IDP_ISSUER: 'https://test-issuer.example.com/', + IDP_AUDIENCE: 'test-audience', + AUTH0_DOMAIN: 'test.auth0.com', + AUTH0_CLIENT_ID: 'test-client-id', + AUTH0_CLIENT_SECRET: 'test-client-secret', + AUTH0_ROLES_NAME: 'https://bri.us/roles', + }), + ], + }), + TypeOrmModule.forRoot({ + type: 'sqlite', + database: ':memory:', + synchronize: true, + entities: [User, Account, UserAccount], + logging: false, + }), + ClsModule.forRoot({ + global: true, + middleware: { mount: false }, + }), + UsersModule, + AccountsModule, + ], + }).compile(); + + app = moduleFixture.createNestApplication(); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ); + + await app.init(); + dataSource = moduleFixture.get(DataSource); + }); + + afterAll(async () => { + await app.close(); + }); + + beforeEach(async () => { + // Clear tables before each test + const queryRunner = dataSource.createQueryRunner(); + await queryRunner.query('DELETE FROM users_accounts'); + await queryRunner.query('DELETE FROM users'); + await queryRunner.query('DELETE FROM accounts'); + await queryRunner.release(); + }); + + describe('Factory functions', () => { + it('should create a test user with default values', () => { + const user = createTestUser(); + + expect(user).toBeDefined(); + expect(user.id).toBeDefined(); + expect(user.email).toContain('@example.com'); + expect(user.name).toBe('Test User'); + expect(user.status).toBe('accepted'); + expect(user.isSuperAdmin).toBe(false); + }); + + it('should create a test user with custom values', () => { + const user = createTestUser({ + email: 'custom@test.com', + name: 'Custom User', + isSuperAdmin: true, + }); + + expect(user.email).toBe('custom@test.com'); + expect(user.name).toBe('Custom User'); + expect(user.isSuperAdmin).toBe(true); + }); + + it('should create a test account with default values', () => { + const account = createTestAccount(); + + expect(account).toBeDefined(); + expect(account.id).toBeDefined(); + expect(account.name).toContain('Test Account'); + expect(account.description).toBe('Test account description'); + }); + }); + + describe('Auth helper functions', () => { + it('should create a valid JWT token', () => { + const token = createTestJwt({ + sub: 'user-123', + email: 'test@example.com', + }); + + expect(token).toBeDefined(); + expect(typeof token).toBe('string'); + expect(token.split('.')).toHaveLength(3); // JWT has 3 parts + }); + + it('should create a proper Authorization header', () => { + const token = createTestJwt({ sub: 'user-123' }); + const header = createAuthHeader(token); + + expect(header).toStartWith('Bearer '); + expect(header).toContain(token); + }); + }); + + describe('GET /users', () => { + it('should return 401 without authentication', async () => { + const response = await request(app.getHttpServer()).get('/users'); + + expect(response.status).toBe(401); + }); + }); +});