From 4b59f832888fa4a7497552f9c730e37df3908436 Mon Sep 17 00:00:00 2001
From: Jason Rasmussen <jrasm91@gmail.com>
Date: Wed, 18 Oct 2023 18:02:42 -0400
Subject: [PATCH] refactor: e2e tests (#4536)

---
 .github/workflows/test.yml                    |  2 +-
 Makefile                                      |  4 +-
 docker/docker-compose.test.yml                | 21 ++---
 server/package.json                           |  2 +-
 .../src/domain/metadata/metadata.service.ts   |  4 +
 .../repositories/metadata.repository.ts       |  1 +
 .../infra/repositories/metadata.repository.ts |  4 +
 server/src/microservices/app.service.ts       |  4 +
 server/test/e2e/album.e2e-spec.ts             | 79 ++++++++++---------
 server/test/e2e/asset.e2e-spec.ts             | 23 +++---
 server/test/e2e/auth.e2e-spec.ts              | 17 ++--
 server/test/e2e/formats.e2e-spec.ts           | 16 ++--
 server/test/e2e/library.e2e-spec.ts           | 24 ++----
 server/test/e2e/oauth.e2e-spec.ts             | 16 ++--
 server/test/e2e/partner.e2e-spec.ts           | 26 +++---
 server/test/e2e/person.e2e-spec.ts            | 14 ++--
 server/test/e2e/server-info.e2e-spec.ts       | 16 ++--
 server/test/e2e/setup.ts                      |  5 +-
 server/test/e2e/shared-link.e2e-spec.ts       | 23 ++----
 server/test/e2e/user.e2e-spec.ts              | 11 ++-
 .../repositories/metadata.repository.mock.ts  |  1 +
 server/test/test-utils.ts                     | 77 ++++++++++--------
 22 files changed, 189 insertions(+), 201 deletions(-)

diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 33b6cf5ccc..dac6b1f8a5 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -21,7 +21,7 @@ jobs:
           submodules: "recursive"
 
       - name: Run e2e tests
-        run: docker-compose -f ./docker/docker-compose.test.yml -p immich-test-e2e up --renew-anon-volumes --abort-on-container-exit --exit-code-from immich-server-test --remove-orphans --build
+        run: docker compose -f ./docker/docker-compose.test.yml up --renew-anon-volumes --abort-on-container-exit --exit-code-from immich-server --remove-orphans --build
 
   doc-tests:
     name: Run documentation checks
diff --git a/Makefile b/Makefile
index a8b86d75c6..504c02c102 100644
--- a/Makefile
+++ b/Makefile
@@ -20,7 +20,7 @@ pull-stage:
 	docker-compose -f ./docker/docker-compose.staging.yml pull
 
 test-e2e:
-	docker-compose -f ./docker/docker-compose.test.yml -p immich-test-e2e up  --renew-anon-volumes --abort-on-container-exit --exit-code-from immich-server-test --remove-orphans --build
+	docker compose -f ./docker/docker-compose.test.yml up --renew-anon-volumes --abort-on-container-exit --exit-code-from immich-server --remove-orphans --build
 
 prod:
 	docker-compose -f ./docker/docker-compose.prod.yml up --build -V --remove-orphans
@@ -32,4 +32,4 @@ api:
 	cd ./server && npm run api:generate
 
 attach-server:
-	docker exec -it docker_immich-server_1 sh
\ No newline at end of file
+	docker exec -it docker_immich-server_1 sh
diff --git a/docker/docker-compose.test.yml b/docker/docker-compose.test.yml
index 57b0123340..df965aa1f6 100644
--- a/docker/docker-compose.test.yml
+++ b/docker/docker-compose.test.yml
@@ -1,10 +1,10 @@
 version: "3.8"
 
-# Compose file for dockerized end-to-end testing of the backend
+name: "immich-test-e2e"
 
 services:
-  immich-server-test:
-    image: immich-server-test
+  immich-server:
+    image: immich-server-dev:latest
     build:
       context: ../server
       dockerfile: Dockerfile
@@ -14,27 +14,20 @@ services:
       - ../server:/usr/src/app
       - /usr/src/app/node_modules
     environment:
-      - DB_HOSTNAME=immich-database-test
+      - DB_HOSTNAME=database
       - DB_USERNAME=postgres
       - DB_PASSWORD=postgres
       - DB_DATABASE_NAME=e2e_test
       - IMMICH_RUN_ALL_TESTS=true
     depends_on:
-      - immich-database-test
-    networks:
-      - immich-test-network
+      - database
 
-  immich-database-test:
-    container_name: immich-database-test
+  database:
     image: postgres:14-alpine@sha256:28407a9961e76f2d285dc6991e8e48893503cc3836a4755bbc2d40bcc272a441
+    command: -c fsync=off
     environment:
       POSTGRES_PASSWORD: postgres
       POSTGRES_USER: postgres
       POSTGRES_DB: e2e_test
-    networks:
-      - immich-test-network
     logging:
       driver: none
-
-networks:
-  immich-test-network:
diff --git a/server/package.json b/server/package.json
index acbd2f334b..def7a64aca 100644
--- a/server/package.json
+++ b/server/package.json
@@ -26,7 +26,7 @@
     "test:watch": "jest --watch",
     "test:cov": "jest --coverage",
     "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
-    "test:e2e": "NODE_OPTIONS='--experimental-vm-modules --max_old_space_size=4096' jest --config test/e2e/jest-e2e.json --runInBand --forceExit",
+    "test:e2e": "NODE_OPTIONS='--experimental-vm-modules --max_old_space_size=4096' jest --config test/e2e/jest-e2e.json --runInBand",
     "typeorm": "typeorm",
     "typeorm:migrations:create": "typeorm migration:create",
     "typeorm:migrations:generate": "typeorm migration:generate -d ./dist/infra/database.config.js",
diff --git a/server/src/domain/metadata/metadata.service.ts b/server/src/domain/metadata/metadata.service.ts
index 2779df54c6..9a7b4f3e59 100644
--- a/server/src/domain/metadata/metadata.service.ts
+++ b/server/src/domain/metadata/metadata.service.ts
@@ -109,6 +109,10 @@ export class MetadataService {
     }
   }
 
+  async teardown() {
+    await this.repository.teardown();
+  }
+
   async handleLivePhotoLinking(job: IEntityJob) {
     const { id } = job;
     const [asset] = await this.assetRepository.getByIds([id]);
diff --git a/server/src/domain/repositories/metadata.repository.ts b/server/src/domain/repositories/metadata.repository.ts
index a037964f47..084a655c7a 100644
--- a/server/src/domain/repositories/metadata.repository.ts
+++ b/server/src/domain/repositories/metadata.repository.ts
@@ -26,6 +26,7 @@ export interface ImmichTags extends Omit<Tags, 'FocalLength'> {
 
 export interface IMetadataRepository {
   init(options: Partial<InitOptions>): Promise<void>;
+  teardown(): Promise<void>;
   reverseGeocode(point: GeoPoint): Promise<ReverseGeocodeResult>;
   deleteCache(): Promise<void>;
   getExifTags(path: string): Promise<ImmichTags | null>;
diff --git a/server/src/infra/repositories/metadata.repository.ts b/server/src/infra/repositories/metadata.repository.ts
index 3cb53e823d..63bc29dcba 100644
--- a/server/src/infra/repositories/metadata.repository.ts
+++ b/server/src/infra/repositories/metadata.repository.ts
@@ -45,6 +45,10 @@ export class MetadataRepository implements IMetadataRepository {
     });
   }
 
+  async teardown() {
+    await exiftool.end();
+  }
+
   async deleteCache() {
     const dumpDirectory = REVERSE_GEOCODING_DUMP_DIRECTORY;
     if (dumpDirectory) {
diff --git a/server/src/microservices/app.service.ts b/server/src/microservices/app.service.ts
index 1513c62975..365b073299 100644
--- a/server/src/microservices/app.service.ts
+++ b/server/src/microservices/app.service.ts
@@ -103,4 +103,8 @@ export class AppService {
     await this.metadataService.init();
     await this.searchService.init();
   }
+
+  async teardown() {
+    await this.metadataService.teardown();
+  }
 }
diff --git a/server/test/e2e/album.e2e-spec.ts b/server/test/e2e/album.e2e-spec.ts
index 633a825a76..e10f5414f9 100644
--- a/server/test/e2e/album.e2e-spec.ts
+++ b/server/test/e2e/album.e2e-spec.ts
@@ -2,11 +2,10 @@ import { AlbumResponseDto, LoginResponseDto } from '@app/domain';
 import { AlbumController } from '@app/immich';
 import { AssetFileUploadResponseDto } from '@app/immich/api-v1/asset/response-dto/asset-file-upload-response.dto';
 import { SharedLinkType } from '@app/infra/entities';
-import { INestApplication } from '@nestjs/common';
 import { api } from '@test/api';
 import { db } from '@test/db';
 import { errorStub, uuidStub } from '@test/fixtures';
-import { createTestApp } from '@test/test-utils';
+import { testApp } from '@test/test-utils';
 import request from 'supertest';
 
 const user1SharedUser = 'user1SharedUser';
@@ -17,7 +16,6 @@ const user2SharedLink = 'user2SharedLink';
 const user2NotShared = 'user2NotShared';
 
 describe(`${AlbumController.name} (e2e)`, () => {
-  let app: INestApplication;
   let server: any;
   let admin: LoginResponseDto;
   let user1: LoginResponseDto;
@@ -27,9 +25,11 @@ describe(`${AlbumController.name} (e2e)`, () => {
   let user2Albums: AlbumResponseDto[];
 
   beforeAll(async () => {
-    app = await createTestApp();
+    [server] = await testApp.create();
+  });
 
-    server = app.getHttpServer();
+  afterAll(async () => {
+    await testApp.teardown();
   });
 
   beforeEach(async () => {
@@ -37,24 +37,30 @@ describe(`${AlbumController.name} (e2e)`, () => {
     await api.authApi.adminSignUp(server);
     admin = await api.authApi.adminLogin(server);
 
-    await api.userApi.create(server, admin.accessToken, {
-      email: 'user1@immich.app',
-      password: 'Password123',
-      firstName: 'User 1',
-      lastName: 'Test',
-    });
-    user1 = await api.authApi.login(server, { email: 'user1@immich.app', password: 'Password123' });
+    await Promise.all([
+      api.userApi.create(server, admin.accessToken, {
+        email: 'user1@immich.app',
+        password: 'Password123',
+        firstName: 'User 1',
+        lastName: 'Test',
+      }),
+      api.userApi.create(server, admin.accessToken, {
+        email: 'user2@immich.app',
+        password: 'Password123',
+        firstName: 'User 2',
+        lastName: 'Test',
+      }),
+    ]);
 
-    await api.userApi.create(server, admin.accessToken, {
-      email: 'user2@immich.app',
-      password: 'Password123',
-      firstName: 'User 2',
-      lastName: 'Test',
-    });
-    user2 = await api.authApi.login(server, { email: 'user2@immich.app', password: 'Password123' });
+    [user1, user2] = await Promise.all([
+      api.authApi.login(server, { email: 'user1@immich.app', password: 'Password123' }),
+      api.authApi.login(server, { email: 'user2@immich.app', password: 'Password123' }),
+    ]);
 
     user1Asset = await api.assetApi.upload(server, user1.accessToken, 'example');
-    user1Albums = await Promise.all([
+
+    const albums = await Promise.all([
+      // user 1
       api.albumApi.create(server, user1.accessToken, {
         albumName: user1SharedUser,
         sharedWithUserIds: [user2.userId],
@@ -62,15 +68,8 @@ describe(`${AlbumController.name} (e2e)`, () => {
       }),
       api.albumApi.create(server, user1.accessToken, { albumName: user1SharedLink, assetIds: [user1Asset.id] }),
       api.albumApi.create(server, user1.accessToken, { albumName: user1NotShared, assetIds: [user1Asset.id] }),
-    ]);
 
-    // add shared link to user1SharedLink album
-    await api.sharedLinkApi.create(server, user1.accessToken, {
-      type: SharedLinkType.ALBUM,
-      albumId: user1Albums[1].id,
-    });
-
-    user2Albums = await Promise.all([
+      // user 2
       api.albumApi.create(server, user2.accessToken, {
         albumName: user2SharedUser,
         sharedWithUserIds: [user1.userId],
@@ -80,16 +79,22 @@ describe(`${AlbumController.name} (e2e)`, () => {
       api.albumApi.create(server, user2.accessToken, { albumName: user2NotShared }),
     ]);
 
-    // add shared link to user2SharedLink album
-    await api.sharedLinkApi.create(server, user2.accessToken, {
-      type: SharedLinkType.ALBUM,
-      albumId: user2Albums[1].id,
-    });
-  });
+    user1Albums = albums.slice(0, 3);
+    user2Albums = albums.slice(3);
 
-  afterAll(async () => {
-    await db.disconnect();
-    await app.close();
+    await Promise.all([
+      // add shared link to user1SharedLink album
+      api.sharedLinkApi.create(server, user1.accessToken, {
+        type: SharedLinkType.ALBUM,
+        albumId: user1Albums[1].id,
+      }),
+
+      // add shared link to user2SharedLink album
+      api.sharedLinkApi.create(server, user2.accessToken, {
+        type: SharedLinkType.ALBUM,
+        albumId: user2Albums[1].id,
+      }),
+    ]);
   });
 
   describe('GET /album', () => {
diff --git a/server/test/e2e/asset.e2e-spec.ts b/server/test/e2e/asset.e2e-spec.ts
index 4f4021d59e..c18268502b 100644
--- a/server/test/e2e/asset.e2e-spec.ts
+++ b/server/test/e2e/asset.e2e-spec.ts
@@ -12,7 +12,7 @@ import { AssetEntity, AssetType, SharedLinkType } from '@app/infra/entities';
 import { INestApplication } from '@nestjs/common';
 import { api } from '@test/api';
 import { errorStub, uuidStub } from '@test/fixtures';
-import { createTestApp, db } from '@test/test-utils';
+import { db, testApp } from '@test/test-utils';
 import { randomBytes } from 'crypto';
 import request from 'supertest';
 
@@ -86,12 +86,14 @@ describe(`${AssetController.name} (e2e)`, () => {
   let asset4: AssetEntity;
 
   beforeAll(async () => {
-    app = await createTestApp();
-
-    server = app.getHttpServer();
+    [server, app] = await testApp.create();
     assetRepository = app.get<IAssetRepository>(IAssetRepository);
   });
 
+  afterAll(async () => {
+    await testApp.teardown();
+  });
+
   beforeEach(async () => {
     await db.reset();
     await api.authApi.adminSignUp(server);
@@ -123,11 +125,6 @@ describe(`${AssetController.name} (e2e)`, () => {
     });
   });
 
-  afterAll(async () => {
-    await db.disconnect();
-    await app.close();
-  });
-
   describe('POST /asset/upload', () => {
     it('should require authentication', async () => {
       const { status, body } = await request(server)
@@ -589,9 +586,11 @@ describe(`${AssetController.name} (e2e)`, () => {
 
   describe('GET /asset/map-marker', () => {
     beforeEach(async () => {
-      await assetRepository.save({ id: asset1.id, isArchived: true });
-      await assetRepository.upsertExif({ assetId: asset1.id, latitude: 0, longitude: 0 });
-      await assetRepository.upsertExif({ assetId: asset2.id, latitude: 0, longitude: 0 });
+      await Promise.all([
+        assetRepository.save({ id: asset1.id, isArchived: true }),
+        assetRepository.upsertExif({ assetId: asset1.id, latitude: 0, longitude: 0 }),
+        assetRepository.upsertExif({ assetId: asset2.id, latitude: 0, longitude: 0 }),
+      ]);
     });
 
     it('should require authentication', async () => {
diff --git a/server/test/e2e/auth.e2e-spec.ts b/server/test/e2e/auth.e2e-spec.ts
index 4068634e74..a42e1e1618 100644
--- a/server/test/e2e/auth.e2e-spec.ts
+++ b/server/test/e2e/auth.e2e-spec.ts
@@ -1,5 +1,4 @@
 import { AuthController } from '@app/immich';
-import { INestApplication } from '@nestjs/common';
 import { api } from '@test/api';
 import { db } from '@test/db';
 import {
@@ -12,7 +11,7 @@ import {
   signupResponseStub,
   uuidStub,
 } from '@test/fixtures';
-import { createTestApp } from '@test/test-utils';
+import { testApp } from '@test/test-utils';
 import request from 'supertest';
 
 const firstName = 'Immich';
@@ -21,13 +20,16 @@ const password = 'Password123';
 const email = 'admin@immich.app';
 
 describe(`${AuthController.name} (e2e)`, () => {
-  let app: INestApplication;
   let server: any;
   let accessToken: string;
 
   beforeAll(async () => {
-    app = await createTestApp();
-    server = app.getHttpServer();
+    await testApp.reset();
+    [server] = await testApp.create();
+  });
+
+  afterAll(async () => {
+    await testApp.teardown();
   });
 
   beforeEach(async () => {
@@ -37,11 +39,6 @@ describe(`${AuthController.name} (e2e)`, () => {
     accessToken = response.accessToken;
   });
 
-  afterAll(async () => {
-    await db.disconnect();
-    await app.close();
-  });
-
   describe('POST /auth/admin-sign-up', () => {
     beforeEach(async () => {
       await db.reset();
diff --git a/server/test/e2e/formats.e2e-spec.ts b/server/test/e2e/formats.e2e-spec.ts
index 98e24ec9ac..f2fce83acd 100644
--- a/server/test/e2e/formats.e2e-spec.ts
+++ b/server/test/e2e/formats.e2e-spec.ts
@@ -1,11 +1,9 @@
 import { LoginResponseDto } from '@app/domain';
 import { AssetType, LibraryType } from '@app/infra/entities';
-import { INestApplication } from '@nestjs/common';
 import { api } from '@test/api';
-import { IMMICH_TEST_ASSET_PATH, createTestApp, db, runAllTests } from '@test/test-utils';
+import { IMMICH_TEST_ASSET_PATH, db, runAllTests, testApp } from '@test/test-utils';
 
 describe(`Supported file formats (e2e)`, () => {
-  let app: INestApplication;
   let server: any;
   let admin: LoginResponseDto;
 
@@ -170,8 +168,11 @@ describe(`Supported file formats (e2e)`, () => {
   const testsToRun = formatTests.filter((formatTest) => formatTest.runTest);
 
   beforeAll(async () => {
-    app = await createTestApp(true);
-    server = app.getHttpServer();
+    [server] = await testApp.create({ jobs: true });
+  });
+
+  afterAll(async () => {
+    await testApp.teardown();
   });
 
   beforeEach(async () => {
@@ -181,11 +182,6 @@ describe(`Supported file formats (e2e)`, () => {
     await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, '/');
   });
 
-  afterAll(async () => {
-    await db.disconnect();
-    await app.close();
-  });
-
   it.each(testsToRun)('should import file of format $format', async (testedFormat) => {
     const library = await api.libraryApi.create(server, admin.accessToken, {
       type: LibraryType.EXTERNAL,
diff --git a/server/test/e2e/library.e2e-spec.ts b/server/test/e2e/library.e2e-spec.ts
index 742e6b7fed..9cfbe89610 100644
--- a/server/test/e2e/library.e2e-spec.ts
+++ b/server/test/e2e/library.e2e-spec.ts
@@ -1,22 +1,14 @@
 import { LibraryResponseDto, LoginResponseDto } from '@app/domain';
 import { LibraryController } from '@app/immich';
 import { AssetType, LibraryType } from '@app/infra/entities';
-import { INestApplication } from '@nestjs/common';
 import { api } from '@test/api';
-import {
-  IMMICH_TEST_ASSET_PATH,
-  IMMICH_TEST_ASSET_TEMP_PATH,
-  createTestApp,
-  db,
-  restoreTempFolder,
-} from '@test/test-utils';
+import { IMMICH_TEST_ASSET_PATH, IMMICH_TEST_ASSET_TEMP_PATH, db, restoreTempFolder, testApp } from '@test/test-utils';
 import * as fs from 'fs';
 import request from 'supertest';
 import { utimes } from 'utimes';
 import { errorStub, uuidStub } from '../fixtures';
 
 describe(`${LibraryController.name} (e2e)`, () => {
-  let app: INestApplication;
   let server: any;
   let admin: LoginResponseDto;
 
@@ -35,8 +27,12 @@ describe(`${LibraryController.name} (e2e)`, () => {
   };
 
   beforeAll(async () => {
-    app = await createTestApp(true);
-    server = app.getHttpServer();
+    [server] = await testApp.create({ jobs: true });
+  });
+
+  afterAll(async () => {
+    await testApp.teardown();
+    await restoreTempFolder();
   });
 
   beforeEach(async () => {
@@ -46,12 +42,6 @@ describe(`${LibraryController.name} (e2e)`, () => {
     admin = await api.authApi.adminLogin(server);
   });
 
-  afterAll(async () => {
-    await db.disconnect();
-    await app.close();
-    await restoreTempFolder();
-  });
-
   describe('GET /library', () => {
     it('should require authentication', async () => {
       const { status, body } = await request(server).get('/library');
diff --git a/server/test/e2e/oauth.e2e-spec.ts b/server/test/e2e/oauth.e2e-spec.ts
index d0d2137c64..879d538152 100644
--- a/server/test/e2e/oauth.e2e-spec.ts
+++ b/server/test/e2e/oauth.e2e-spec.ts
@@ -1,18 +1,19 @@
 import { OAuthController } from '@app/immich';
-import { INestApplication } from '@nestjs/common';
 import { api } from '@test/api';
 import { db } from '@test/db';
 import { errorStub } from '@test/fixtures';
-import { createTestApp } from '@test/test-utils';
+import { testApp } from '@test/test-utils';
 import request from 'supertest';
 
 describe(`${OAuthController.name} (e2e)`, () => {
-  let app: INestApplication;
   let server: any;
 
   beforeAll(async () => {
-    app = await createTestApp();
-    server = app.getHttpServer();
+    [server] = await testApp.create();
+  });
+
+  afterAll(async () => {
+    await testApp.teardown();
   });
 
   beforeEach(async () => {
@@ -20,11 +21,6 @@ describe(`${OAuthController.name} (e2e)`, () => {
     await api.authApi.adminSignUp(server);
   });
 
-  afterAll(async () => {
-    await db.disconnect();
-    await app.close();
-  });
-
   describe('POST /oauth/authorize', () => {
     beforeEach(async () => {
       await db.reset();
diff --git a/server/test/e2e/partner.e2e-spec.ts b/server/test/e2e/partner.e2e-spec.ts
index b0eb1d4ce7..82a09dcc8d 100644
--- a/server/test/e2e/partner.e2e-spec.ts
+++ b/server/test/e2e/partner.e2e-spec.ts
@@ -4,7 +4,7 @@ import { INestApplication } from '@nestjs/common';
 import { api } from '@test/api';
 import { db } from '@test/db';
 import { errorStub } from '@test/fixtures';
-import { createTestApp } from '@test/test-utils';
+import { testApp } from '@test/test-utils';
 import request from 'supertest';
 
 const user1Dto = {
@@ -31,27 +31,29 @@ describe(`${PartnerController.name} (e2e)`, () => {
   let user2: LoginResponseDto;
 
   beforeAll(async () => {
-    app = await createTestApp();
-    server = app.getHttpServer();
+    [server, app] = await testApp.create();
     repository = app.get<IPartnerRepository>(IPartnerRepository);
   });
 
+  afterAll(async () => {
+    await testApp.teardown();
+  });
+
   beforeEach(async () => {
     await db.reset();
     await api.authApi.adminSignUp(server);
     loginResponse = await api.authApi.adminLogin(server);
     accessToken = loginResponse.accessToken;
 
-    await api.userApi.create(server, accessToken, user1Dto);
-    user1 = await api.authApi.login(server, { email: user1Dto.email, password: user1Dto.password });
+    await Promise.all([
+      api.userApi.create(server, accessToken, user1Dto),
+      api.userApi.create(server, accessToken, user2Dto),
+    ]);
 
-    await api.userApi.create(server, accessToken, user2Dto);
-    user2 = await api.authApi.login(server, { email: user2Dto.email, password: user2Dto.password });
-  });
-
-  afterAll(async () => {
-    await db.disconnect();
-    await app.close();
+    [user1, user2] = await Promise.all([
+      api.authApi.login(server, { email: user1Dto.email, password: user1Dto.password }),
+      api.authApi.login(server, { email: user2Dto.email, password: user2Dto.password }),
+    ]);
   });
 
   describe('GET /partner', () => {
diff --git a/server/test/e2e/person.e2e-spec.ts b/server/test/e2e/person.e2e-spec.ts
index f9da56fa81..bb0af4c96c 100644
--- a/server/test/e2e/person.e2e-spec.ts
+++ b/server/test/e2e/person.e2e-spec.ts
@@ -5,7 +5,7 @@ import { INestApplication } from '@nestjs/common';
 import { api } from '@test/api';
 import { db } from '@test/db';
 import { errorStub, uuidStub } from '@test/fixtures';
-import { createTestApp } from '@test/test-utils';
+import { testApp } from '@test/test-utils';
 import request from 'supertest';
 
 describe(`${PersonController.name}`, () => {
@@ -18,11 +18,14 @@ describe(`${PersonController.name}`, () => {
   let hiddenPerson: PersonEntity;
 
   beforeAll(async () => {
-    app = await createTestApp();
-    server = app.getHttpServer();
+    [server, app] = await testApp.create();
     personRepository = app.get<IPersonRepository>(IPersonRepository);
   });
 
+  afterAll(async () => {
+    await testApp.teardown();
+  });
+
   beforeEach(async () => {
     await db.reset();
     await api.authApi.adminSignUp(server);
@@ -46,11 +49,6 @@ describe(`${PersonController.name}`, () => {
     await personRepository.createFace({ assetId: faceAsset.id, personId: hiddenPerson.id });
   });
 
-  afterAll(async () => {
-    await db.disconnect();
-    await app.close();
-  });
-
   describe('GET /person', () => {
     beforeEach(async () => {});
 
diff --git a/server/test/e2e/server-info.e2e-spec.ts b/server/test/e2e/server-info.e2e-spec.ts
index 43cf471f40..cd6afbc07b 100644
--- a/server/test/e2e/server-info.e2e-spec.ts
+++ b/server/test/e2e/server-info.e2e-spec.ts
@@ -1,21 +1,22 @@
 import { LoginResponseDto } from '@app/domain';
 import { ServerInfoController } from '@app/immich';
-import { INestApplication } from '@nestjs/common';
 import { api } from '@test/api';
 import { db } from '@test/db';
 import { errorStub } from '@test/fixtures';
-import { createTestApp } from '@test/test-utils';
+import { testApp } from '@test/test-utils';
 import request from 'supertest';
 
 describe(`${ServerInfoController.name} (e2e)`, () => {
-  let app: INestApplication;
   let server: any;
   let accessToken: string;
   let loginResponse: LoginResponseDto;
 
   beforeAll(async () => {
-    app = await createTestApp();
-    server = app.getHttpServer();
+    [server] = await testApp.create();
+  });
+
+  afterAll(async () => {
+    await testApp.teardown();
   });
 
   beforeEach(async () => {
@@ -25,11 +26,6 @@ describe(`${ServerInfoController.name} (e2e)`, () => {
     accessToken = loginResponse.accessToken;
   });
 
-  afterAll(async () => {
-    await db.disconnect();
-    await app.close();
-  });
-
   describe('GET /server-info', () => {
     it('should require authentication', async () => {
       const { status, body } = await request(server).get('/server-info');
diff --git a/server/test/e2e/setup.ts b/server/test/e2e/setup.ts
index 26849f4686..234deb7545 100644
--- a/server/test/e2e/setup.ts
+++ b/server/test/e2e/setup.ts
@@ -1,5 +1,5 @@
 import { PostgreSqlContainer } from '@testcontainers/postgresql';
-import * as fs from 'fs';
+import { access } from 'fs/promises';
 import path from 'path';
 
 export default async () => {
@@ -23,8 +23,7 @@ export default async () => {
   }
 
   const directoryExists = async (dirPath: string) =>
-    await fs.promises
-      .access(dirPath)
+    await access(dirPath)
       .then(() => true)
       .catch(() => false);
 
diff --git a/server/test/e2e/shared-link.e2e-spec.ts b/server/test/e2e/shared-link.e2e-spec.ts
index 3a52c15a0f..80d43c7c74 100644
--- a/server/test/e2e/shared-link.e2e-spec.ts
+++ b/server/test/e2e/shared-link.e2e-spec.ts
@@ -1,16 +1,10 @@
 import { AlbumResponseDto, LoginResponseDto, SharedLinkResponseDto } from '@app/domain';
 import { PartnerController } from '@app/immich';
 import { LibraryType, SharedLinkType } from '@app/infra/entities';
-import { INestApplication } from '@nestjs/common';
 import { api } from '@test/api';
 import { db } from '@test/db';
 import { errorStub, uuidStub } from '@test/fixtures';
-import {
-  IMMICH_TEST_ASSET_PATH,
-  IMMICH_TEST_ASSET_TEMP_PATH,
-  createTestApp,
-  restoreTempFolder,
-} from '@test/test-utils';
+import { IMMICH_TEST_ASSET_PATH, IMMICH_TEST_ASSET_TEMP_PATH, restoreTempFolder, testApp } from '@test/test-utils';
 import { cp } from 'fs/promises';
 import request from 'supertest';
 
@@ -22,7 +16,6 @@ const user1Dto = {
 };
 
 describe(`${PartnerController.name} (e2e)`, () => {
-  let app: INestApplication;
   let server: any;
   let admin: LoginResponseDto;
   let user1: LoginResponseDto;
@@ -30,8 +23,12 @@ describe(`${PartnerController.name} (e2e)`, () => {
   let sharedLink: SharedLinkResponseDto;
 
   beforeAll(async () => {
-    app = await createTestApp(true);
-    server = app.getHttpServer();
+    [server] = await testApp.create({ jobs: true });
+  });
+
+  afterAll(async () => {
+    await testApp.teardown();
+    await restoreTempFolder();
   });
 
   beforeEach(async () => {
@@ -49,12 +46,6 @@ describe(`${PartnerController.name} (e2e)`, () => {
     });
   });
 
-  afterAll(async () => {
-    await db.disconnect();
-    await app.close();
-    await restoreTempFolder();
-  });
-
   describe('GET /shared-link', () => {
     it('should require authentication', async () => {
       const { status, body } = await request(server).get('/shared-link');
diff --git a/server/test/e2e/user.e2e-spec.ts b/server/test/e2e/user.e2e-spec.ts
index af0cbde745..d20ac729f6 100644
--- a/server/test/e2e/user.e2e-spec.ts
+++ b/server/test/e2e/user.e2e-spec.ts
@@ -2,10 +2,11 @@ import { LoginResponseDto, UserResponseDto, UserService } from '@app/domain';
 import { AppModule, UserController } from '@app/immich';
 import { UserEntity } from '@app/infra/entities';
 import { INestApplication } from '@nestjs/common';
+import { getRepositoryToken } from '@nestjs/typeorm';
 import { api } from '@test/api';
 import { db } from '@test/db';
 import { errorStub, userSignupStub, userStub } from '@test/fixtures';
-import { createTestApp } from '@test/test-utils';
+import { testApp } from '@test/test-utils';
 import request from 'supertest';
 import { Repository } from 'typeorm';
 
@@ -18,10 +19,12 @@ describe(`${UserController.name}`, () => {
   let userRepository: Repository<UserEntity>;
 
   beforeAll(async () => {
-    app = await createTestApp();
-    userRepository = app.select(AppModule).get('UserEntityRepository');
+    [server, app] = await testApp.create();
+    userRepository = app.select(AppModule).get(getRepositoryToken(UserEntity));
+  });
 
-    server = app.getHttpServer();
+  afterAll(async () => {
+    await testApp.teardown();
   });
 
   beforeEach(async () => {
diff --git a/server/test/repositories/metadata.repository.mock.ts b/server/test/repositories/metadata.repository.mock.ts
index 13589f15b8..76c6f777a5 100644
--- a/server/test/repositories/metadata.repository.mock.ts
+++ b/server/test/repositories/metadata.repository.mock.ts
@@ -5,6 +5,7 @@ export const newMetadataRepositoryMock = (): jest.Mocked<IMetadataRepository> =>
     deleteCache: jest.fn(),
     getExifTags: jest.fn(),
     init: jest.fn(),
+    teardown: jest.fn(),
     reverseGeocode: jest.fn(),
   };
 };
diff --git a/server/test/test-utils.ts b/server/test/test-utils.ts
index 075e0b69fc..6b45c6ee6c 100644
--- a/server/test/test-utils.ts
+++ b/server/test/test-utils.ts
@@ -1,9 +1,8 @@
-import { dataSource } from '@app/infra';
-
 import { IJobRepository, JobItem, JobItemHandler, QueueName } from '@app/domain';
 import { AppModule } from '@app/immich';
-import { INestApplication, Logger } from '@nestjs/common';
-import { Test, TestingModule } from '@nestjs/testing';
+import { dataSource } from '@app/infra';
+import { INestApplication } from '@nestjs/common';
+import { Test } from '@nestjs/testing';
 import * as fs from 'fs';
 import path from 'path';
 import { AppService } from '../src/microservices/app.service';
@@ -36,38 +35,48 @@ export const db = {
 
 let _handler: JobItemHandler = () => Promise.resolve();
 
-export async function createTestApp(runJobs = false, log = false): Promise<INestApplication> {
-  const moduleBuilder = Test.createTestingModule({
-    imports: [AppModule],
-    providers: [AppService],
-  })
-    .overrideProvider(IJobRepository)
-    .useValue({
-      addHandler: (_queueName: QueueName, _concurrency: number, handler: JobItemHandler) => (_handler = handler),
-      queue: (item: JobItem) => runJobs && _handler(item),
-      resume: jest.fn(),
-      empty: jest.fn(),
-      setConcurrency: jest.fn(),
-      getQueueStatus: jest.fn(),
-      getJobCounts: jest.fn(),
-      pause: jest.fn(),
-    } as IJobRepository);
-
-  const moduleFixture: TestingModule = await moduleBuilder.compile();
-
-  const app = moduleFixture.createNestApplication();
-  if (log) {
-    app.useLogger(new Logger());
-  } else {
-    app.useLogger(false);
-  }
-  await app.init();
-  const appService = app.get(AppService);
-  await appService.init();
-
-  return app;
+interface TestAppOptions {
+  jobs: boolean;
 }
 
+let app: INestApplication;
+
+export const testApp = {
+  create: async (options?: TestAppOptions): Promise<[any, INestApplication]> => {
+    const { jobs } = options || { jobs: false };
+
+    const moduleFixture = await Test.createTestingModule({ imports: [AppModule], providers: [AppService] })
+      .overrideProvider(IJobRepository)
+      .useValue({
+        addHandler: (_queueName: QueueName, _concurrency: number, handler: JobItemHandler) => (_handler = handler),
+        queue: (item: JobItem) => jobs && _handler(item),
+        resume: jest.fn(),
+        empty: jest.fn(),
+        setConcurrency: jest.fn(),
+        getQueueStatus: jest.fn(),
+        getJobCounts: jest.fn(),
+        pause: jest.fn(),
+      } as IJobRepository)
+      .compile();
+
+    app = await moduleFixture.createNestApplication().init();
+
+    if (jobs) {
+      await app.get(AppService).init();
+    }
+
+    return [app.getHttpServer(), app];
+  },
+  reset: async () => {
+    await db.reset();
+  },
+  teardown: async () => {
+    await app.get(AppService).teardown();
+    await db.disconnect();
+    await app.close();
+  },
+};
+
 export const runAllTests: boolean = process.env.IMMICH_RUN_ALL_TESTS === 'true';
 
 const directoryExists = async (dirPath: string) =>