From 7c851893b47a7887ab6a5a231ccf47ec145fb005 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Tue, 25 Feb 2025 11:31:07 -0500 Subject: [PATCH] feat: medium tests for user and sync service (#16304) Co-authored-by: Zack Pollard --- .github/workflows/test.yml | 21 +- server/package-lock.json | 12 ++ server/package.json | 8 +- server/src/dtos/user.dto.ts | 2 +- server/src/entities/user-metadata.entity.ts | 2 +- server/src/repositories/user.repository.ts | 2 +- server/src/services/user.service.ts | 11 +- server/test/factory.ts | 169 ++++++++++++++++ server/test/medium/globalSetup.ts | 61 ++++++ .../{ => specs}/metadata.service.spec.ts | 0 server/test/medium/specs/sync.service.spec.ts | 189 ++++++++++++++++++ server/test/medium/specs/user.service.spec.ts | 116 +++++++++++ server/test/utils.ts | 64 +++++- server/{ => test}/vitest.config.medium.mjs | 1 + server/{ => test}/vitest.config.mjs | 0 15 files changed, 634 insertions(+), 24 deletions(-) create mode 100644 server/test/factory.ts create mode 100644 server/test/medium/globalSetup.ts rename server/test/medium/{ => specs}/metadata.service.spec.ts (100%) create mode 100644 server/test/medium/specs/sync.service.spec.ts create mode 100644 server/test/medium/specs/user.service.spec.ts rename server/{ => test}/vitest.config.medium.mjs (88%) rename server/{ => test}/vitest.config.mjs (100%) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9d89063f24..e80b6aabb0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -246,25 +246,30 @@ jobs: run: npm run check if: ${{ !cancelled() }} - medium-tests-server: + server-medium-tests: name: Medium Tests (Server) needs: pre-job if: ${{ needs.pre-job.outputs.should_run_server == 'true' }} - runs-on: mich + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./server steps: - name: Checkout code uses: actions/checkout@v4 - with: - submodules: 'recursive' - - name: Production build - if: ${{ !cancelled() }} - run: docker compose -f e2e/docker-compose.yml build + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version-file: './server/.nvmrc' + + - name: Run npm install + run: npm ci - name: Run medium tests + run: npm run test:medium if: ${{ !cancelled() }} - run: make test-medium e2e-tests-server-cli: name: End-to-End Tests (Server & CLI) diff --git a/server/package-lock.json b/server/package-lock.json index 80de8b37ff..ce39195f22 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -77,6 +77,7 @@ "@nestjs/testing": "^11.0.4", "@swc/core": "^1.4.14", "@testcontainers/postgresql": "^10.2.1", + "@testcontainers/redis": "^10.18.0", "@types/archiver": "^6.0.0", "@types/async-lock": "^1.4.2", "@types/bcrypt": "^5.0.0", @@ -113,6 +114,7 @@ "rimraf": "^6.0.0", "source-map-support": "^0.5.21", "sql-formatter": "^15.0.0", + "testcontainers": "^10.18.0", "tsconfig-paths": "^4.2.0", "typescript": "^5.3.3", "unplugin-swc": "^1.4.5", @@ -5619,6 +5621,16 @@ "testcontainers": "^10.18.0" } }, + "node_modules/@testcontainers/redis": { + "version": "10.18.0", + "resolved": "https://registry.npmjs.org/@testcontainers/redis/-/redis-10.18.0.tgz", + "integrity": "sha512-ZRIemaCl7C6ozC6D3PdR7BBfD3roT+EHX3ATIopUCXdemhQ/0gNaCNwt4Zq8akxkf8TvgnJkK/t6+Itm01FcVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "testcontainers": "^10.18.0" + } + }, "node_modules/@turf/boolean-point-in-polygon": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/@turf/boolean-point-in-polygon/-/boolean-point-in-polygon-7.1.0.tgz", diff --git a/server/package.json b/server/package.json index 2d3356bb2c..fa0be7d4e4 100644 --- a/server/package.json +++ b/server/package.json @@ -18,9 +18,9 @@ "check": "tsc --noEmit", "check:code": "npm run format && npm run lint && npm run check", "check:all": "npm run check:code && npm run test:cov", - "test": "vitest", - "test:cov": "vitest --coverage", - "test:medium": "vitest --config vitest.config.medium.mjs", + "test": "vitest --config test/vitest.config.mjs", + "test:cov": "vitest --config test/vitest.config.mjs --coverage", + "test:medium": "vitest --config test/vitest.config.medium.mjs", "typeorm": "typeorm", "lifecycle": "node ./dist/utils/lifecycle.js", "typeorm:migrations:create": "typeorm migration:create", @@ -103,6 +103,7 @@ "@nestjs/testing": "^11.0.4", "@swc/core": "^1.4.14", "@testcontainers/postgresql": "^10.2.1", + "@testcontainers/redis": "^10.18.0", "@types/archiver": "^6.0.0", "@types/async-lock": "^1.4.2", "@types/bcrypt": "^5.0.0", @@ -139,6 +140,7 @@ "rimraf": "^6.0.0", "source-map-support": "^0.5.21", "sql-formatter": "^15.0.0", + "testcontainers": "^10.18.0", "tsconfig-paths": "^4.2.0", "typescript": "^5.3.3", "unplugin-swc": "^1.4.5", diff --git a/server/src/dtos/user.dto.ts b/server/src/dtos/user.dto.ts index a169784ebb..03895aa880 100644 --- a/server/src/dtos/user.dto.ts +++ b/server/src/dtos/user.dto.ts @@ -157,6 +157,6 @@ export function mapUserAdmin(entity: UserEntity): UserAdminResponseDto { quotaSizeInBytes: entity.quotaSizeInBytes, quotaUsageInBytes: entity.quotaUsageInBytes, status: entity.status, - license: license ?? null, + license: license ? { ...license, activatedAt: new Date(license?.activatedAt) } : null, }; } diff --git a/server/src/entities/user-metadata.entity.ts b/server/src/entities/user-metadata.entity.ts index 8282443e0e..8c7a13ed0d 100644 --- a/server/src/entities/user-metadata.entity.ts +++ b/server/src/entities/user-metadata.entity.ts @@ -115,5 +115,5 @@ export const getDefaultPreferences = (user: { email: string }): UserPreferences export interface UserMetadata extends Record> { [UserMetadataKey.PREFERENCES]: DeepPartial; - [UserMetadataKey.LICENSE]: { licenseKey: string; activationKey: string; activatedAt: Date }; + [UserMetadataKey.LICENSE]: { licenseKey: string; activationKey: string; activatedAt: string }; } diff --git a/server/src/repositories/user.repository.ts b/server/src/repositories/user.repository.ts index fccd127378..302f868971 100644 --- a/server/src/repositories/user.repository.ts +++ b/server/src/repositories/user.repository.ts @@ -189,7 +189,7 @@ export class UserRepository { await this.db.deleteFrom('user_metadata').where('userId', '=', id).where('key', '=', key).execute(); } - delete(user: UserEntity, hard?: boolean): Promise { + delete(user: { id: string }, hard?: boolean): Promise { return hard ? (this.db.deleteFrom('users').where('id', '=', user.id).execute() as unknown as Promise) : (this.db diff --git a/server/src/services/user.service.ts b/server/src/services/user.service.ts index ae6e94031f..f7d6018207 100644 --- a/server/src/services/user.service.ts +++ b/server/src/services/user.service.ts @@ -140,7 +140,7 @@ export class UserService extends BaseService { if (!license) { throw new NotFoundException(); } - return license.value; + return { ...license.value, activatedAt: new Date(license.value.activatedAt) }; } async deleteLicense({ user }: AuthDto): Promise { @@ -170,17 +170,14 @@ export class UserService extends BaseService { throw new BadRequestException('Invalid license key'); } - const licenseData = { - ...license, - activatedAt: new Date(), - }; + const activatedAt = new Date(); await this.userRepository.upsertMetadata(auth.user.id, { key: UserMetadataKey.LICENSE, - value: licenseData, + value: { ...license, activatedAt: activatedAt.toISOString() }, }); - return licenseData; + return { ...license, activatedAt }; } @OnJob({ name: JobName.USER_SYNC_USAGE, queue: QueueName.BACKGROUND_TASK }) diff --git a/server/test/factory.ts b/server/test/factory.ts new file mode 100644 index 0000000000..983b7cbb77 --- /dev/null +++ b/server/test/factory.ts @@ -0,0 +1,169 @@ +import { Insertable, Kysely } from 'kysely'; +import { randomBytes, randomUUID } from 'node:crypto'; +import { Writable } from 'node:stream'; +import { Assets, DB, Sessions, Users } from 'src/db'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { AssetType } from 'src/enum'; +import { AlbumRepository } from 'src/repositories/album.repository'; +import { AssetRepository } from 'src/repositories/asset.repository'; +import { SessionRepository } from 'src/repositories/session.repository'; +import { SyncRepository } from 'src/repositories/sync.repository'; +import { UserRepository } from 'src/repositories/user.repository'; + +class CustomWritable extends Writable { + private data = ''; + + _write(chunk: any, encoding: string, callback: () => void) { + this.data += chunk.toString(); + callback(); + } + + getResponse() { + const result = this.data; + return result + .split('\n') + .filter((x) => x.length > 0) + .map((x) => JSON.parse(x)); + } +} + +type Asset = Insertable; +type User = Partial>; +type Session = Omit, 'token'> & { token?: string }; + +export const newUuid = () => randomUUID() as string; + +export class TestFactory { + private assets: Asset[] = []; + private sessions: Session[] = []; + private users: User[] = []; + + private constructor(private context: TestContext) {} + + static create(context: TestContext) { + return new TestFactory(context); + } + + static stream() { + return new CustomWritable(); + } + + static asset(asset: Asset) { + const assetId = asset.id || newUuid(); + const defaults: Insertable = { + deviceAssetId: '', + deviceId: '', + originalFileName: '', + checksum: randomBytes(32), + type: AssetType.IMAGE, + originalPath: '/path/to/something.jpg', + ownerId: '@immich.cloud', + isVisible: true, + }; + + return { + ...defaults, + ...asset, + id: assetId, + }; + } + + static auth(auth: { user: User; session?: Session }) { + return auth as AuthDto; + } + + static user(user: User = {}) { + const userId = user.id || newUuid(); + const defaults: Insertable = { + email: `${userId}@immich.cloud`, + name: `User ${userId}`, + deletedAt: null, + }; + + return { + ...defaults, + ...user, + id: userId, + }; + } + + static session(session: Session) { + const id = session.id || newUuid(); + const defaults = { + token: randomBytes(36).toString('base64url'), + }; + + return { + ...defaults, + ...session, + id, + }; + } + + withAsset(asset: Asset) { + this.assets.push(asset); + return this; + } + + withSession(session: Session) { + this.sessions.push(session); + return this; + } + + withUser(user: User = {}) { + this.users.push(user); + return this; + } + + async create() { + for (const asset of this.assets) { + await this.context.createAsset(asset); + } + + for (const user of this.users) { + await this.context.createUser(user); + } + + for (const session of this.sessions) { + await this.context.createSession(session); + } + + return this.context; + } +} + +export class TestContext { + userRepository: UserRepository; + assetRepository: AssetRepository; + albumRepository: AlbumRepository; + sessionRepository: SessionRepository; + syncRepository: SyncRepository; + + private constructor(private db: Kysely) { + this.userRepository = new UserRepository(this.db); + this.assetRepository = new AssetRepository(this.db); + this.albumRepository = new AlbumRepository(this.db); + this.sessionRepository = new SessionRepository(this.db); + this.syncRepository = new SyncRepository(this.db); + } + + static from(db: Kysely) { + return new TestContext(db).getFactory(); + } + + getFactory() { + return TestFactory.create(this); + } + + createUser(user: User = {}) { + return this.userRepository.create(TestFactory.user(user)); + } + + createAsset(asset: Asset) { + return this.assetRepository.create(TestFactory.asset(asset)); + } + + createSession(session: Session) { + return this.sessionRepository.create(TestFactory.session(session)); + } +} diff --git a/server/test/medium/globalSetup.ts b/server/test/medium/globalSetup.ts new file mode 100644 index 0000000000..c6a37148c4 --- /dev/null +++ b/server/test/medium/globalSetup.ts @@ -0,0 +1,61 @@ +import { GenericContainer, Wait } from 'testcontainers'; +import { DataSource } from 'typeorm'; + +const globalSetup = async () => { + const postgres = await new GenericContainer('tensorchord/pgvecto-rs:pg14-v0.2.0') + .withExposedPorts(5432) + .withEnvironment({ + POSTGRES_PASSWORD: 'postgres', + POSTGRES_USER: 'postgres', + POSTGRES_DB: 'immich', + }) + .withCommand([ + 'postgres', + '-c', + 'shared_preload_libraries=vectors.so', + '-c', + 'search_path="$$user", public, vectors', + '-c', + 'max_wal_size=2GB', + '-c', + 'shared_buffers=512MB', + '-c', + 'fsync=off', + '-c', + 'full_page_writes=off', + '-c', + 'synchronous_commit=off', + ]) + .withWaitStrategy(Wait.forAll([Wait.forLogMessage('database system is ready to accept connections', 2)])) + .start(); + + const postgresPort = postgres.getMappedPort(5432); + const postgresUrl = `postgres://postgres:postgres@localhost:${postgresPort}/immich`; + process.env.IMMICH_TEST_POSTGRES_URL = postgresUrl; + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + const modules = import.meta.glob('/src/migrations/*.ts', { eager: true }); + + const config = { + type: 'postgres' as const, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + migrations: Object.values(modules).map((module) => Object.values(module)[0]), + migrationsRun: false, + synchronize: false, + connectTimeoutMS: 10_000, // 10 seconds + parseInt8: true, + url: postgresUrl, + }; + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + const dataSource = new DataSource(config); + await dataSource.initialize(); + await dataSource.query('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"'); + await dataSource.runMigrations(); + await dataSource.destroy(); +}; + +export default globalSetup; diff --git a/server/test/medium/metadata.service.spec.ts b/server/test/medium/specs/metadata.service.spec.ts similarity index 100% rename from server/test/medium/metadata.service.spec.ts rename to server/test/medium/specs/metadata.service.spec.ts diff --git a/server/test/medium/specs/sync.service.spec.ts b/server/test/medium/specs/sync.service.spec.ts new file mode 100644 index 0000000000..bab9794100 --- /dev/null +++ b/server/test/medium/specs/sync.service.spec.ts @@ -0,0 +1,189 @@ +import { AuthDto } from 'src/dtos/auth.dto'; +import { SyncRequestType } from 'src/enum'; +import { SyncService } from 'src/services/sync.service'; +import { TestContext, TestFactory } from 'test/factory'; +import { getKyselyDB, newTestService } from 'test/utils'; + +const setup = async () => { + const user = TestFactory.user(); + const session = TestFactory.session({ userId: user.id }); + const auth = TestFactory.auth({ session, user }); + + const db = await getKyselyDB(); + + const context = await TestContext.from(db).withUser(user).withSession(session).create(); + + const { sut } = newTestService(SyncService, context); + + const testSync = async (auth: AuthDto, types: SyncRequestType[]) => { + const stream = TestFactory.stream(); + await sut.stream(auth, stream, { types }); + + return stream.getResponse(); + }; + + return { + auth, + context, + sut, + testSync, + }; +}; + +describe(SyncService.name, () => { + describe.concurrent('users', () => { + it('should detect and sync the first user', async () => { + const { context, auth, sut, testSync } = await setup(); + + const user = await context.userRepository.get(auth.user.id, { withDeleted: false }); + if (!user) { + expect.fail('First user should exist'); + } + + const initialSyncResponse = await testSync(auth, [SyncRequestType.UsersV1]); + expect(initialSyncResponse).toHaveLength(1); + expect(initialSyncResponse).toEqual([ + { + ack: expect.any(String), + data: { + deletedAt: user.deletedAt, + email: user.email, + id: user.id, + name: user.name, + }, + type: 'UserV1', + }, + ]); + + const acks = [initialSyncResponse[0].ack]; + await sut.setAcks(auth, { acks }); + const ackSyncResponse = await testSync(auth, [SyncRequestType.UsersV1]); + + expect(ackSyncResponse).toHaveLength(0); + }); + + it('should detect and sync a soft deleted user', async () => { + const { auth, context, sut, testSync } = await setup(); + + const deletedAt = new Date().toISOString(); + const deleted = await context.createUser({ deletedAt }); + + const response = await testSync(auth, [SyncRequestType.UsersV1]); + + expect(response).toHaveLength(2); + expect(response).toEqual( + expect.arrayContaining([ + { + ack: expect.any(String), + data: { + deletedAt: null, + email: auth.user.email, + id: auth.user.id, + name: auth.user.name, + }, + type: 'UserV1', + }, + { + ack: expect.any(String), + data: { + deletedAt, + email: deleted.email, + id: deleted.id, + name: deleted.name, + }, + type: 'UserV1', + }, + ]), + ); + + const acks = [response[1].ack]; + await sut.setAcks(auth, { acks }); + const ackSyncResponse = await testSync(auth, [SyncRequestType.UsersV1]); + + expect(ackSyncResponse).toHaveLength(0); + }); + + it('should detect and sync a deleted user', async () => { + const { auth, context, sut, testSync } = await setup(); + + const user = await context.createUser(); + await context.userRepository.delete({ id: user.id }, true); + + const response = await testSync(auth, [SyncRequestType.UsersV1]); + + expect(response).toHaveLength(2); + expect(response).toEqual( + expect.arrayContaining([ + { + ack: expect.any(String), + data: { + userId: user.id, + }, + type: 'UserDeleteV1', + }, + { + ack: expect.any(String), + data: { + deletedAt: null, + email: auth.user.email, + id: auth.user.id, + name: auth.user.name, + }, + type: 'UserV1', + }, + ]), + ); + + const acks = response.map(({ ack }) => ack); + await sut.setAcks(auth, { acks }); + const ackSyncResponse = await testSync(auth, [SyncRequestType.UsersV1]); + + expect(ackSyncResponse).toHaveLength(0); + }); + + it('should sync a user and then an update to that same user', async () => { + const { auth, context, sut, testSync } = await setup(); + + const initialSyncResponse = await testSync(auth, [SyncRequestType.UsersV1]); + + expect(initialSyncResponse).toHaveLength(1); + expect(initialSyncResponse).toEqual( + expect.arrayContaining([ + { + ack: expect.any(String), + data: { + deletedAt: null, + email: auth.user.email, + id: auth.user.id, + name: auth.user.name, + }, + type: 'UserV1', + }, + ]), + ); + + const acks = [initialSyncResponse[0].ack]; + await sut.setAcks(auth, { acks }); + + const updated = await context.userRepository.update(auth.user.id, { name: 'new name' }); + + const updatedSyncResponse = await testSync(auth, [SyncRequestType.UsersV1]); + + expect(updatedSyncResponse).toHaveLength(1); + expect(updatedSyncResponse).toEqual( + expect.arrayContaining([ + { + ack: expect.any(String), + data: { + deletedAt: null, + email: auth.user.email, + id: auth.user.id, + name: updated.name, + }, + type: 'UserV1', + }, + ]), + ); + }); + }); +}); diff --git a/server/test/medium/specs/user.service.spec.ts b/server/test/medium/specs/user.service.spec.ts new file mode 100644 index 0000000000..6750dd38d8 --- /dev/null +++ b/server/test/medium/specs/user.service.spec.ts @@ -0,0 +1,116 @@ +import { UserService } from 'src/services/user.service'; +import { TestContext, TestFactory } from 'test/factory'; +import { getKyselyDB, newTestService } from 'test/utils'; + +describe.concurrent(UserService.name, () => { + let sut: UserService; + let context: TestContext; + + beforeAll(async () => { + const db = await getKyselyDB(); + context = await TestContext.from(db).withUser({ isAdmin: true }).create(); + ({ sut } = newTestService(UserService, context)); + }); + + describe('create', () => { + it('should create a user', async () => { + const userDto = TestFactory.user(); + + await expect(sut.createUser(userDto)).resolves.toEqual( + expect.objectContaining({ + id: userDto.id, + name: userDto.name, + email: userDto.email, + }), + ); + }); + + it('should reject user with duplicate email', async () => { + const userDto = TestFactory.user(); + const userDto2 = TestFactory.user({ email: userDto.email }); + + await sut.createUser(userDto); + + await expect(sut.createUser(userDto2)).rejects.toThrow('User exists'); + }); + + it('should not return password', async () => { + const user = await sut.createUser(TestFactory.user()); + + expect((user as any).password).toBeUndefined(); + }); + }); + + describe('get', () => { + it('should get a user', async () => { + const userDto = TestFactory.user(); + + await context.createUser(userDto); + + await expect(sut.get(userDto.id)).resolves.toEqual( + expect.objectContaining({ + id: userDto.id, + name: userDto.name, + email: userDto.email, + }), + ); + }); + + it('should not return password', async () => { + const { id } = await context.createUser(); + + const user = await sut.get(id); + + expect((user as any).password).toBeUndefined(); + }); + }); + + describe('updateMe', () => { + it('should update a user', async () => { + const userDto = TestFactory.user(); + const sessionDto = TestFactory.session({ userId: userDto.id }); + const authDto = TestFactory.auth({ user: userDto }); + + const before = await context.createUser(userDto); + await context.createSession(sessionDto); + + const newUserDto = TestFactory.user(); + + const after = await sut.updateMe(authDto, { name: newUserDto.name, email: newUserDto.email }); + + if (!before || !after) { + expect.fail('User should be found'); + } + + expect(before.updatedAt).toBeDefined(); + expect(after.updatedAt).toBeDefined(); + expect(before.updatedAt).not.toEqual(after.updatedAt); + expect(after).toEqual(expect.objectContaining({ name: newUserDto.name, email: newUserDto.email })); + }); + }); + + describe('setLicense', () => { + const userLicense = { + licenseKey: 'IMCL-FF69-TUK1-RWZU-V9Q8-QGQS-S5GC-X4R2-UFK4', + activationKey: + 'KuX8KsktrBSiXpQMAH0zLgA5SpijXVr_PDkzLdWUlAogCTMBZ0I3KCHXK0eE9EEd7harxup8_EHMeqAWeHo5VQzol6LGECpFv585U9asXD4Zc-UXt3mhJr2uhazqipBIBwJA2YhmUCDy8hiyiGsukDQNu9Rg9C77UeoKuZBWVjWUBWG0mc1iRqfvF0faVM20w53czAzlhaMxzVGc3Oimbd7xi_CAMSujF_2y8QpA3X2fOVkQkzdcH9lV0COejl7IyH27zQQ9HrlrXv3Lai5Hw67kNkaSjmunVBxC5PS0TpKoc9SfBJMaAGWnaDbjhjYUrm-8nIDQnoeEAidDXVAdPw', + }; + it('should set a license', async () => { + const userDto = TestFactory.user(); + const sessionDto = TestFactory.session({ userId: userDto.id }); + const authDto = TestFactory.auth({ user: userDto }); + + await context.getFactory().withUser(userDto).withSession(sessionDto).create(); + + await expect(sut.getLicense(authDto)).rejects.toThrowError(); + + const after = await sut.setLicense(authDto, userLicense); + + expect(after.licenseKey).toEqual(userLicense.licenseKey); + expect(after.activationKey).toEqual(userLicense.activationKey); + + const getResponse = await sut.getLicense(authDto); + expect(getResponse).toEqual(after); + }); + }); +}); diff --git a/server/test/utils.ts b/server/test/utils.ts index ca2272f6b8..8f65ec614d 100644 --- a/server/test/utils.ts +++ b/server/test/utils.ts @@ -1,6 +1,11 @@ +import { Kysely, sql } from 'kysely'; +import { PostgresJSDialect } from 'kysely-postgres-js'; import { ChildProcessWithoutNullStreams } from 'node:child_process'; import { Writable } from 'node:stream'; +import { parse } from 'pg-connection-string'; import { PNG } from 'pngjs'; +import postgres, { Notice } from 'postgres'; +import { DB } from 'src/db'; import { ImmichWorker } from 'src/enum'; import { AccessRepository } from 'src/repositories/access.repository'; import { ActivityRepository } from 'src/repositories/activity.repository'; @@ -90,6 +95,8 @@ import { Mocked, vitest } from 'vitest'; type Overrides = { worker?: ImmichWorker; metadataRepository?: MetadataRepository; + syncRepository?: SyncRepository; + userRepository?: UserRepository; }; type BaseServiceArgs = ConstructorParameters; type Constructor> = { @@ -144,7 +151,7 @@ export const newTestService = ( Service: Constructor, overrides?: Overrides, ) => { - const { metadataRepository } = overrides || {}; + const { metadataRepository, userRepository, syncRepository } = overrides || {}; const accessMock = newAccessRepositoryMock(); const loggerMock = newLoggingRepositoryMock(); @@ -180,12 +187,12 @@ export const newTestService = ( const sharedLinkMock = newSharedLinkRepositoryMock(); const stackMock = newStackRepositoryMock(); const storageMock = newStorageRepositoryMock(); - const syncMock = newSyncRepositoryMock(); + const syncMock = (syncRepository || newSyncRepositoryMock()) as Mocked>; const systemMock = newSystemMetadataRepositoryMock(); const tagMock = newTagRepositoryMock(); const telemetryMock = newTelemetryRepositoryMock(); const trashMock = newTrashRepositoryMock(); - const userMock = newUserRepositoryMock(); + const userMock = (userRepository || newUserRepositoryMock()) as Mocked>; const versionHistoryMock = newVersionHistoryRepositoryMock(); const viewMock = newViewRepositoryMock(); @@ -299,6 +306,57 @@ function* newPngFactory() { const pngFactory = newPngFactory(); +export const getKyselyDB = async (suffix?: string): Promise> => { + const parsed = parse(process.env.IMMICH_TEST_POSTGRES_URL!); + + const parsedOptions = { + ...parsed, + ssl: false, + host: parsed.host ?? undefined, + port: parsed.port ? Number(parsed.port) : undefined, + database: parsed.database ?? undefined, + }; + + const driverOptions = { + ...parsedOptions, + onnotice: (notice: Notice) => { + if (notice['severity'] !== 'NOTICE') { + console.warn('Postgres notice:', notice); + } + }, + max: 10, + types: { + date: { + to: 1184, + from: [1082, 1114, 1184], + serialize: (x: Date | string) => (x instanceof Date ? x.toISOString() : x), + parse: (x: string) => new Date(x), + }, + bigint: { + to: 20, + from: [20], + parse: (value: string) => Number.parseInt(value), + serialize: (value: number) => value.toString(), + }, + }, + connection: { + TimeZone: 'UTC', + }, + }; + + const kysely = new Kysely({ + dialect: new PostgresJSDialect({ postgres: postgres({ ...driverOptions, max: 1, database: 'postgres' }) }), + }); + const randomSuffix = Math.random().toString(36).slice(2, 7); + const dbName = `immich_${suffix ?? randomSuffix}`; + + await sql.raw(`CREATE DATABASE ${dbName} WITH TEMPLATE immich OWNER postgres;`).execute(kysely); + + return new Kysely({ + dialect: new PostgresJSDialect({ postgres: postgres({ ...driverOptions, database: dbName }) }), + }); +}; + export const newRandomImage = () => { const { value } = pngFactory.next(); if (!value) { diff --git a/server/vitest.config.medium.mjs b/server/test/vitest.config.medium.mjs similarity index 88% rename from server/vitest.config.medium.mjs rename to server/test/vitest.config.medium.mjs index 40dad8d6a5..fe6a93accb 100644 --- a/server/vitest.config.medium.mjs +++ b/server/test/vitest.config.medium.mjs @@ -7,6 +7,7 @@ export default defineConfig({ root: './', globals: true, include: ['test/medium/**/*.spec.ts'], + globalSetup: ['test/medium/globalSetup.ts'], server: { deps: { fallbackCJS: true, diff --git a/server/vitest.config.mjs b/server/test/vitest.config.mjs similarity index 100% rename from server/vitest.config.mjs rename to server/test/vitest.config.mjs