From b733a294306e2a622decf9c9d83771ce78bc4e3e Mon Sep 17 00:00:00 2001
From: Jason Rasmussen <jrasm91@gmail.com>
Date: Thu, 7 Mar 2024 10:14:36 -0500
Subject: [PATCH] refactor: e2e (#7703)

* refactor: e2e

* fix: submodule check

* chore: extend startup timeout
---
 e2e/package.json                            |   3 +-
 e2e/src/api/specs/activity.e2e-spec.ts      |  13 +-
 e2e/src/api/specs/album.e2e-spec.ts         |  43 ++--
 e2e/src/api/specs/asset.e2e-spec.ts         |  95 ++++---
 e2e/src/api/specs/audit.e2e-spec.ts         |  15 +-
 e2e/src/api/specs/auth.e2e-spec.ts          |  12 +-
 e2e/src/api/specs/download.e2e-spec.ts      |  18 +-
 e2e/src/api/specs/library.e2e-spec.ts       |  21 +-
 e2e/src/api/specs/oauth.e2e-spec.ts         |  14 +-
 e2e/src/api/specs/partner.e2e-spec.ts       |  13 +-
 e2e/src/api/specs/person.e2e-spec.ts        |  31 ++-
 e2e/src/api/specs/server-info.e2e-spec.ts   |   9 +-
 e2e/src/api/specs/shared-link.e2e-spec.ts   |  28 +-
 e2e/src/api/specs/system-config.e2e-spec.ts |   9 +-
 e2e/src/api/specs/trash.e2e-spec.ts         |  33 ++-
 e2e/src/api/specs/user.e2e-spec.ts          |  13 +-
 e2e/src/cli/specs/login.e2e-spec.ts         |  18 +-
 e2e/src/cli/specs/server-info.e2e-spec.ts   |   7 +-
 e2e/src/cli/specs/upload.e2e-spec.ts        |   9 +-
 e2e/src/cli/specs/version.e2e-spec.ts       |   8 +-
 e2e/src/setup.ts                            |  11 +-
 e2e/src/utils.ts                            | 271 +++++++++-----------
 e2e/src/web/specs/auth.e2e-spec.ts          |  14 +-
 e2e/src/web/specs/shared-link.e2e-spec.ts   |  18 +-
 e2e/vitest.config.ts                        |   1 +
 25 files changed, 332 insertions(+), 395 deletions(-)

diff --git a/e2e/package.json b/e2e/package.json
index 9f231c9ddd..14685df51b 100644
--- a/e2e/package.json
+++ b/e2e/package.json
@@ -5,7 +5,8 @@
   "main": "index.js",
   "type": "module",
   "scripts": {
-    "test": "vitest --config vitest.config.ts",
+    "test": "vitest --run",
+    "test:watch": "vitest",
     "test:web": "npx playwright test",
     "start:web": "npx playwright test --ui",
     "format": "prettier --check .",
diff --git a/e2e/src/api/specs/activity.e2e-spec.ts b/e2e/src/api/specs/activity.e2e-spec.ts
index 365ad66dc4..5d3cf72209 100644
--- a/e2e/src/api/specs/activity.e2e-spec.ts
+++ b/e2e/src/api/specs/activity.e2e-spec.ts
@@ -9,7 +9,7 @@ import {
 } from '@immich/sdk';
 import { createUserDto, uuidDto } from 'src/fixtures';
 import { errorDto } from 'src/responses';
-import { apiUtils, app, asBearerAuth, dbUtils } from 'src/utils';
+import { app, asBearerAuth, utils } from 'src/utils';
 import request from 'supertest';
 import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
 
@@ -23,12 +23,11 @@ describe('/activity', () => {
     create({ activityCreateDto: dto }, { headers: asBearerAuth(accessToken || admin.accessToken) });
 
   beforeAll(async () => {
-    apiUtils.setup();
-    await dbUtils.reset();
+    await utils.resetDatabase();
 
-    admin = await apiUtils.adminSetup();
-    nonOwner = await apiUtils.userSetup(admin.accessToken, createUserDto.user1);
-    asset = await apiUtils.createAsset(admin.accessToken);
+    admin = await utils.adminSetup();
+    nonOwner = await utils.userSetup(admin.accessToken, createUserDto.user1);
+    asset = await utils.createAsset(admin.accessToken);
     album = await createAlbum(
       {
         createAlbumDto: {
@@ -42,7 +41,7 @@ describe('/activity', () => {
   });
 
   beforeEach(async () => {
-    await dbUtils.reset(['activity']);
+    await utils.resetDatabase(['activity']);
   });
 
   describe('GET /activity', () => {
diff --git a/e2e/src/api/specs/album.e2e-spec.ts b/e2e/src/api/specs/album.e2e-spec.ts
index 99a50106ed..4faa5eac3d 100644
--- a/e2e/src/api/specs/album.e2e-spec.ts
+++ b/e2e/src/api/specs/album.e2e-spec.ts
@@ -7,7 +7,7 @@ import {
 } from '@immich/sdk';
 import { createUserDto, uuidDto } from 'src/fixtures';
 import { errorDto } from 'src/responses';
-import { apiUtils, app, asBearerAuth, dbUtils } from 'src/utils';
+import { app, asBearerAuth, utils } from 'src/utils';
 import request from 'supertest';
 import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
 
@@ -29,49 +29,48 @@ describe('/album', () => {
   let user3: LoginResponseDto; // deleted
 
   beforeAll(async () => {
-    apiUtils.setup();
-    await dbUtils.reset();
+    await utils.resetDatabase();
 
-    admin = await apiUtils.adminSetup();
+    admin = await utils.adminSetup();
 
     [user1, user2, user3] = await Promise.all([
-      apiUtils.userSetup(admin.accessToken, createUserDto.user1),
-      apiUtils.userSetup(admin.accessToken, createUserDto.user2),
-      apiUtils.userSetup(admin.accessToken, createUserDto.user3),
+      utils.userSetup(admin.accessToken, createUserDto.user1),
+      utils.userSetup(admin.accessToken, createUserDto.user2),
+      utils.userSetup(admin.accessToken, createUserDto.user3),
     ]);
 
     [user1Asset1, user1Asset2] = await Promise.all([
-      apiUtils.createAsset(user1.accessToken, { isFavorite: true }),
-      apiUtils.createAsset(user1.accessToken),
+      utils.createAsset(user1.accessToken, { isFavorite: true }),
+      utils.createAsset(user1.accessToken),
     ]);
 
     const albums = await Promise.all([
       // user 1
-      apiUtils.createAlbum(user1.accessToken, {
+      utils.createAlbum(user1.accessToken, {
         albumName: user1SharedUser,
         sharedWithUserIds: [user2.userId],
         assetIds: [user1Asset1.id],
       }),
-      apiUtils.createAlbum(user1.accessToken, {
+      utils.createAlbum(user1.accessToken, {
         albumName: user1SharedLink,
         assetIds: [user1Asset1.id],
       }),
-      apiUtils.createAlbum(user1.accessToken, {
+      utils.createAlbum(user1.accessToken, {
         albumName: user1NotShared,
         assetIds: [user1Asset1.id, user1Asset2.id],
       }),
 
       // user 2
-      apiUtils.createAlbum(user2.accessToken, {
+      utils.createAlbum(user2.accessToken, {
         albumName: user2SharedUser,
         sharedWithUserIds: [user1.userId],
         assetIds: [user1Asset1.id],
       }),
-      apiUtils.createAlbum(user2.accessToken, { albumName: user2SharedLink }),
-      apiUtils.createAlbum(user2.accessToken, { albumName: user2NotShared }),
+      utils.createAlbum(user2.accessToken, { albumName: user2SharedLink }),
+      utils.createAlbum(user2.accessToken, { albumName: user2NotShared }),
 
       // user 3
-      apiUtils.createAlbum(user3.accessToken, {
+      utils.createAlbum(user3.accessToken, {
         albumName: 'Deleted',
         sharedWithUserIds: [user1.userId],
       }),
@@ -82,12 +81,12 @@ describe('/album', () => {
 
     await Promise.all([
       // add shared link to user1SharedLink album
-      apiUtils.createSharedLink(user1.accessToken, {
+      utils.createSharedLink(user1.accessToken, {
         type: SharedLinkType.Album,
         albumId: user1Albums[1].id,
       }),
       // add shared link to user2SharedLink album
-      apiUtils.createSharedLink(user2.accessToken, {
+      utils.createSharedLink(user2.accessToken, {
         type: SharedLinkType.Album,
         albumId: user2Albums[1].id,
       }),
@@ -366,7 +365,7 @@ describe('/album', () => {
     });
 
     it('should be able to add own asset to own album', async () => {
-      const asset = await apiUtils.createAsset(user1.accessToken);
+      const asset = await utils.createAsset(user1.accessToken);
       const { status, body } = await request(app)
         .put(`/album/${user1Albums[0].id}/assets`)
         .set('Authorization', `Bearer ${user1.accessToken}`)
@@ -377,7 +376,7 @@ describe('/album', () => {
     });
 
     it('should be able to add own asset to shared album', async () => {
-      const asset = await apiUtils.createAsset(user1.accessToken);
+      const asset = await utils.createAsset(user1.accessToken);
       const { status, body } = await request(app)
         .put(`/album/${user2Albums[0].id}/assets`)
         .set('Authorization', `Bearer ${user1.accessToken}`)
@@ -398,7 +397,7 @@ describe('/album', () => {
     });
 
     it('should update an album', async () => {
-      const album = await apiUtils.createAlbum(user1.accessToken, {
+      const album = await utils.createAlbum(user1.accessToken, {
         albumName: 'New album',
       });
       const { status, body } = await request(app)
@@ -485,7 +484,7 @@ describe('/album', () => {
     let album: AlbumResponseDto;
 
     beforeEach(async () => {
-      album = await apiUtils.createAlbum(user1.accessToken, {
+      album = await utils.createAlbum(user1.accessToken, {
         albumName: 'testAlbum',
       });
     });
diff --git a/e2e/src/api/specs/asset.e2e-spec.ts b/e2e/src/api/specs/asset.e2e-spec.ts
index 30dfa5d643..f1bb355315 100644
--- a/e2e/src/api/specs/asset.e2e-spec.ts
+++ b/e2e/src/api/specs/asset.e2e-spec.ts
@@ -12,7 +12,7 @@ import { basename, join } from 'node:path';
 import { Socket } from 'socket.io-client';
 import { createUserDto, uuidDto } from 'src/fixtures';
 import { errorDto } from 'src/responses';
-import { apiUtils, app, dbUtils, fileUtils, tempDir, testAssetDir, wsUtils } from 'src/utils';
+import { app, tempDir, testAssetDir, utils } from 'src/utils';
 import request from 'supertest';
 import { afterAll, beforeAll, describe, expect, it } from 'vitest';
 
@@ -44,42 +44,41 @@ describe('/asset', () => {
   let ws: Socket;
 
   beforeAll(async () => {
-    apiUtils.setup();
-    await dbUtils.reset();
-    admin = await apiUtils.adminSetup({ onboarding: false });
+    await utils.resetDatabase();
+    admin = await utils.adminSetup({ onboarding: false });
 
     [ws, user1, user2, userStats] = await Promise.all([
-      wsUtils.connect(admin.accessToken),
-      apiUtils.userSetup(admin.accessToken, createUserDto.user1),
-      apiUtils.userSetup(admin.accessToken, createUserDto.user2),
-      apiUtils.userSetup(admin.accessToken, createUserDto.user3),
+      utils.connectWebsocket(admin.accessToken),
+      utils.userSetup(admin.accessToken, createUserDto.user1),
+      utils.userSetup(admin.accessToken, createUserDto.user2),
+      utils.userSetup(admin.accessToken, createUserDto.user3),
     ]);
 
     // asset location
-    assetLocation = await apiUtils.createAsset(admin.accessToken, {
+    assetLocation = await utils.createAsset(admin.accessToken, {
       assetData: {
         filename: 'thompson-springs.jpg',
         bytes: await readFile(locationAssetFilepath),
       },
     });
 
-    await wsUtils.waitForEvent({ event: 'upload', assetId: assetLocation.id });
+    await utils.waitForWebsocketEvent({ event: 'upload', assetId: assetLocation.id });
 
     user1Assets = await Promise.all([
-      apiUtils.createAsset(user1.accessToken),
-      apiUtils.createAsset(user1.accessToken),
-      apiUtils.createAsset(user1.accessToken, {
+      utils.createAsset(user1.accessToken),
+      utils.createAsset(user1.accessToken),
+      utils.createAsset(user1.accessToken, {
         isFavorite: true,
         isReadOnly: true,
         fileCreatedAt: yesterday.toISO(),
         fileModifiedAt: yesterday.toISO(),
         assetData: { filename: 'example.mp4' },
       }),
-      apiUtils.createAsset(user1.accessToken),
-      apiUtils.createAsset(user1.accessToken),
+      utils.createAsset(user1.accessToken),
+      utils.createAsset(user1.accessToken),
     ]);
 
-    user2Assets = await Promise.all([apiUtils.createAsset(user2.accessToken)]);
+    user2Assets = await Promise.all([utils.createAsset(user2.accessToken)]);
 
     for (const asset of [...user1Assets, ...user2Assets]) {
       expect(asset.duplicate).toBe(false);
@@ -87,27 +86,27 @@ describe('/asset', () => {
 
     await Promise.all([
       // stats
-      apiUtils.createAsset(userStats.accessToken),
-      apiUtils.createAsset(userStats.accessToken, { isFavorite: true }),
-      apiUtils.createAsset(userStats.accessToken, { isArchived: true }),
-      apiUtils.createAsset(userStats.accessToken, {
+      utils.createAsset(userStats.accessToken),
+      utils.createAsset(userStats.accessToken, { isFavorite: true }),
+      utils.createAsset(userStats.accessToken, { isArchived: true }),
+      utils.createAsset(userStats.accessToken, {
         isArchived: true,
         isFavorite: true,
         assetData: { filename: 'example.mp4' },
       }),
     ]);
 
-    const person1 = await apiUtils.createPerson(user1.accessToken, {
+    const person1 = await utils.createPerson(user1.accessToken, {
       name: 'Test Person',
     });
-    await dbUtils.createFace({
+    await utils.createFace({
       assetId: user1Assets[0].id,
       personId: person1.id,
     });
   }, 30_000);
 
   afterAll(() => {
-    wsUtils.disconnect(ws);
+    utils.disconnectWebsocket(ws);
   });
 
   describe('GET /asset/:id', () => {
@@ -142,7 +141,7 @@ describe('/asset', () => {
     });
 
     it('should work with a shared link', async () => {
-      const sharedLink = await apiUtils.createSharedLink(user1.accessToken, {
+      const sharedLink = await utils.createSharedLink(user1.accessToken, {
         type: SharedLinkType.Individual,
         assetIds: [user1Assets[0].id],
       });
@@ -172,7 +171,7 @@ describe('/asset', () => {
         ],
       });
 
-      const sharedLink = await apiUtils.createSharedLink(user1.accessToken, {
+      const sharedLink = await utils.createSharedLink(user1.accessToken, {
         type: SharedLinkType.Individual,
         assetIds: [user1Assets[0].id],
       });
@@ -244,12 +243,12 @@ describe('/asset', () => {
   describe('GET /asset/random', () => {
     beforeAll(async () => {
       await Promise.all([
-        apiUtils.createAsset(user1.accessToken),
-        apiUtils.createAsset(user1.accessToken),
-        apiUtils.createAsset(user1.accessToken),
-        apiUtils.createAsset(user1.accessToken),
-        apiUtils.createAsset(user1.accessToken),
-        apiUtils.createAsset(user1.accessToken),
+        utils.createAsset(user1.accessToken),
+        utils.createAsset(user1.accessToken),
+        utils.createAsset(user1.accessToken),
+        utils.createAsset(user1.accessToken),
+        utils.createAsset(user1.accessToken),
+        utils.createAsset(user1.accessToken),
       ]);
     });
 
@@ -332,7 +331,7 @@ describe('/asset', () => {
     });
 
     it('should favorite an asset', async () => {
-      const before = await apiUtils.getAssetInfo(user1.accessToken, user1Assets[0].id);
+      const before = await utils.getAssetInfo(user1.accessToken, user1Assets[0].id);
       expect(before.isFavorite).toBe(false);
 
       const { status, body } = await request(app)
@@ -344,7 +343,7 @@ describe('/asset', () => {
     });
 
     it('should archive an asset', async () => {
-      const before = await apiUtils.getAssetInfo(user1.accessToken, user1Assets[0].id);
+      const before = await utils.getAssetInfo(user1.accessToken, user1Assets[0].id);
       expect(before.isArchived).toBe(false);
 
       const { status, body } = await request(app)
@@ -472,9 +471,9 @@ describe('/asset', () => {
     });
 
     it('should move an asset to the trash', async () => {
-      const { id: assetId } = await apiUtils.createAsset(admin.accessToken);
+      const { id: assetId } = await utils.createAsset(admin.accessToken);
 
-      const before = await apiUtils.getAssetInfo(admin.accessToken, assetId);
+      const before = await utils.getAssetInfo(admin.accessToken, assetId);
       expect(before.isTrashed).toBe(false);
 
       const { status } = await request(app)
@@ -483,7 +482,7 @@ describe('/asset', () => {
         .set('Authorization', `Bearer ${admin.accessToken}`);
       expect(status).toBe(204);
 
-      const after = await apiUtils.getAssetInfo(admin.accessToken, assetId);
+      const after = await utils.getAssetInfo(admin.accessToken, assetId);
       expect(after.isTrashed).toBe(true);
     });
   });
@@ -604,15 +603,15 @@ describe('/asset', () => {
     for (const { input, expected } of tests) {
       it(`should generate a thumbnail for ${input}`, async () => {
         const filepath = join(testAssetDir, input);
-        const { id, duplicate } = await apiUtils.createAsset(admin.accessToken, {
+        const { id, duplicate } = await utils.createAsset(admin.accessToken, {
           assetData: { bytes: await readFile(filepath), filename: basename(filepath) },
         });
 
         expect(duplicate).toBe(false);
 
-        await wsUtils.waitForEvent({ event: 'upload', assetId: id });
+        await utils.waitForWebsocketEvent({ event: 'upload', assetId: id });
 
-        const asset = await apiUtils.getAssetInfo(admin.accessToken, id);
+        const asset = await utils.getAssetInfo(admin.accessToken, id);
 
         expect(asset.exifInfo).toBeDefined();
         expect(asset.exifInfo).toMatchObject(expected.exifInfo);
@@ -622,7 +621,7 @@ describe('/asset', () => {
 
     it('should handle a duplicate', async () => {
       const filepath = 'formats/jpeg/el_torcal_rocks.jpeg';
-      const { duplicate } = await apiUtils.createAsset(admin.accessToken, {
+      const { duplicate } = await utils.createAsset(admin.accessToken, {
         assetData: {
           bytes: await readFile(join(testAssetDir, filepath)),
           filename: basename(filepath),
@@ -654,21 +653,21 @@ describe('/asset', () => {
 
     for (const { filepath, checksum } of motionTests) {
       it(`should extract motionphoto video from ${filepath}`, async () => {
-        const response = await apiUtils.createAsset(admin.accessToken, {
+        const response = await utils.createAsset(admin.accessToken, {
           assetData: {
             bytes: await readFile(join(testAssetDir, filepath)),
             filename: basename(filepath),
           },
         });
 
-        await wsUtils.waitForEvent({ event: 'upload', assetId: response.id });
+        await utils.waitForWebsocketEvent({ event: 'upload', assetId: response.id });
 
         expect(response.duplicate).toBe(false);
 
-        const asset = await apiUtils.getAssetInfo(admin.accessToken, response.id);
+        const asset = await utils.getAssetInfo(admin.accessToken, response.id);
         expect(asset.livePhotoVideoId).toBeDefined();
 
-        const video = await apiUtils.getAssetInfo(admin.accessToken, asset.livePhotoVideoId as string);
+        const video = await utils.getAssetInfo(admin.accessToken, asset.livePhotoVideoId as string);
         expect(video.checksum).toStrictEqual(checksum);
       });
     }
@@ -687,7 +686,7 @@ describe('/asset', () => {
         .get(`/asset/thumbnail/${assetLocation.id}?format=WEBP`)
         .set('Authorization', `Bearer ${admin.accessToken}`);
 
-      await wsUtils.waitForEvent({
+      await utils.waitForWebsocketEvent({
         event: 'upload',
         assetId: assetLocation.id,
       });
@@ -733,11 +732,11 @@ describe('/asset', () => {
       expect(body).toBeDefined();
       expect(type).toBe('image/jpeg');
 
-      const asset = await apiUtils.getAssetInfo(admin.accessToken, assetLocation.id);
+      const asset = await utils.getAssetInfo(admin.accessToken, assetLocation.id);
 
       const original = await readFile(locationAssetFilepath);
-      const originalChecksum = fileUtils.sha1(original);
-      const downloadChecksum = fileUtils.sha1(body);
+      const originalChecksum = utils.sha1(original);
+      const downloadChecksum = utils.sha1(body);
 
       expect(originalChecksum).toBe(downloadChecksum);
       expect(downloadChecksum).toBe(asset.checksum);
diff --git a/e2e/src/api/specs/audit.e2e-spec.ts b/e2e/src/api/specs/audit.e2e-spec.ts
index 13c753039d..2b551fd24c 100644
--- a/e2e/src/api/specs/audit.e2e-spec.ts
+++ b/e2e/src/api/specs/audit.e2e-spec.ts
@@ -1,24 +1,23 @@
 import { deleteAssets, getAuditFiles, updateAsset, type LoginResponseDto } from '@immich/sdk';
-import { apiUtils, asBearerAuth, dbUtils, fileUtils } from 'src/utils';
+import { asBearerAuth, utils } from 'src/utils';
 import { beforeAll, describe, expect, it } from 'vitest';
 
 describe('/audit', () => {
   let admin: LoginResponseDto;
 
   beforeAll(async () => {
-    apiUtils.setup();
-    await dbUtils.reset();
-    await fileUtils.reset();
+    await utils.resetDatabase();
+    await utils.resetFilesystem();
 
-    admin = await apiUtils.adminSetup();
+    admin = await utils.adminSetup();
   });
 
   describe('GET :/file-report', () => {
     it('excludes assets without issues from report', async () => {
       const [trashedAsset, archivedAsset] = await Promise.all([
-        apiUtils.createAsset(admin.accessToken),
-        apiUtils.createAsset(admin.accessToken),
-        apiUtils.createAsset(admin.accessToken),
+        utils.createAsset(admin.accessToken),
+        utils.createAsset(admin.accessToken),
+        utils.createAsset(admin.accessToken),
       ]);
 
       await Promise.all([
diff --git a/e2e/src/api/specs/auth.e2e-spec.ts b/e2e/src/api/specs/auth.e2e-spec.ts
index a58e215718..28445f79d9 100644
--- a/e2e/src/api/specs/auth.e2e-spec.ts
+++ b/e2e/src/api/specs/auth.e2e-spec.ts
@@ -1,19 +1,15 @@
 import { LoginResponseDto, getAuthDevices, login, signUpAdmin } from '@immich/sdk';
 import { loginDto, signupDto, uuidDto } from 'src/fixtures';
 import { deviceDto, errorDto, loginResponseDto, signupResponseDto } from 'src/responses';
-import { apiUtils, app, asBearerAuth, dbUtils } from 'src/utils';
+import { app, asBearerAuth, utils } from 'src/utils';
 import request from 'supertest';
-import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
+import { beforeEach, describe, expect, it } from 'vitest';
 
 const { name, email, password } = signupDto.admin;
 
 describe(`/auth/admin-sign-up`, () => {
-  beforeAll(() => {
-    apiUtils.setup();
-  });
-
   beforeEach(async () => {
-    await dbUtils.reset();
+    await utils.resetDatabase();
   });
 
   describe('POST /auth/admin-sign-up', () => {
@@ -84,7 +80,7 @@ describe('/auth/*', () => {
   let admin: LoginResponseDto;
 
   beforeEach(async () => {
-    await dbUtils.reset();
+    await utils.resetDatabase();
     await signUpAdmin({ signUpDto: signupDto.admin });
     admin = await login({ loginCredentialDto: loginDto.admin });
   });
diff --git a/e2e/src/api/specs/download.e2e-spec.ts b/e2e/src/api/specs/download.e2e-spec.ts
index af328934b4..ef14778dac 100644
--- a/e2e/src/api/specs/download.e2e-spec.ts
+++ b/e2e/src/api/specs/download.e2e-spec.ts
@@ -1,7 +1,7 @@
 import { AssetFileUploadResponseDto, LoginResponseDto } from '@immich/sdk';
 import { readFile, writeFile } from 'node:fs/promises';
 import { errorDto } from 'src/responses';
-import { apiUtils, app, dbUtils, fileUtils, tempDir } from 'src/utils';
+import { app, tempDir, utils } from 'src/utils';
 import request from 'supertest';
 import { beforeAll, describe, expect, it } from 'vitest';
 
@@ -11,13 +11,9 @@ describe('/download', () => {
   let asset2: AssetFileUploadResponseDto;
 
   beforeAll(async () => {
-    apiUtils.setup();
-    await dbUtils.reset();
-    admin = await apiUtils.adminSetup();
-    [asset1, asset2] = await Promise.all([
-      apiUtils.createAsset(admin.accessToken),
-      apiUtils.createAsset(admin.accessToken),
-    ]);
+    await utils.resetDatabase();
+    admin = await utils.adminSetup();
+    [asset1, asset2] = await Promise.all([utils.createAsset(admin.accessToken), utils.createAsset(admin.accessToken)]);
   });
 
   describe('POST /download/info', () => {
@@ -65,15 +61,15 @@ describe('/download', () => {
       expect(body instanceof Buffer).toBe(true);
 
       await writeFile(`${tempDir}/archive.zip`, body);
-      await fileUtils.unzip(`${tempDir}/archive.zip`, `${tempDir}/archive`);
+      await utils.unzip(`${tempDir}/archive.zip`, `${tempDir}/archive`);
       const files = [
         { filename: 'example.png', id: asset1.id },
         { filename: 'example+1.png', id: asset2.id },
       ];
       for (const { id, filename } of files) {
         const bytes = await readFile(`${tempDir}/archive/${filename}`);
-        const asset = await apiUtils.getAssetInfo(admin.accessToken, id);
-        expect(fileUtils.sha1(bytes)).toBe(asset.checksum);
+        const asset = await utils.getAssetInfo(admin.accessToken, id);
+        expect(utils.sha1(bytes)).toBe(asset.checksum);
       }
     });
   });
diff --git a/e2e/src/api/specs/library.e2e-spec.ts b/e2e/src/api/specs/library.e2e-spec.ts
index 8213cc86ea..e8f9a46bb2 100644
--- a/e2e/src/api/specs/library.e2e-spec.ts
+++ b/e2e/src/api/specs/library.e2e-spec.ts
@@ -1,7 +1,7 @@
 import { LibraryResponseDto, LibraryType, LoginResponseDto, getAllLibraries } from '@immich/sdk';
 import { userDto, uuidDto } from 'src/fixtures';
 import { errorDto } from 'src/responses';
-import { apiUtils, app, asBearerAuth, dbUtils, testAssetDirInternal } from 'src/utils';
+import { app, asBearerAuth, testAssetDirInternal, utils } from 'src/utils';
 import request from 'supertest';
 import { beforeAll, describe, expect, it } from 'vitest';
 
@@ -11,11 +11,10 @@ describe('/library', () => {
   let library: LibraryResponseDto;
 
   beforeAll(async () => {
-    apiUtils.setup();
-    await dbUtils.reset();
-    admin = await apiUtils.adminSetup();
-    user = await apiUtils.userSetup(admin.accessToken, userDto.user1);
-    library = await apiUtils.createLibrary(admin.accessToken, { type: LibraryType.External });
+    await utils.resetDatabase();
+    admin = await utils.adminSetup();
+    user = await utils.userSetup(admin.accessToken, userDto.user1);
+    library = await utils.createLibrary(admin.accessToken, { type: LibraryType.External });
   });
 
   describe('GET /library', () => {
@@ -303,7 +302,7 @@ describe('/library', () => {
     });
 
     it('should get library by id', async () => {
-      const library = await apiUtils.createLibrary(admin.accessToken, { type: LibraryType.External });
+      const library = await utils.createLibrary(admin.accessToken, { type: LibraryType.External });
 
       const { status, body } = await request(app)
         .get(`/library/${library.id}`)
@@ -359,7 +358,7 @@ describe('/library', () => {
     });
 
     it('should delete an external library', async () => {
-      const library = await apiUtils.createLibrary(admin.accessToken, { type: LibraryType.External });
+      const library = await utils.createLibrary(admin.accessToken, { type: LibraryType.External });
 
       const { status, body } = await request(app)
         .delete(`/library/${library.id}`)
@@ -415,14 +414,14 @@ describe('/library', () => {
     });
 
     it('should pass with no import paths', async () => {
-      const response = await apiUtils.validateLibrary(admin.accessToken, library.id, { importPaths: [] });
+      const response = await utils.validateLibrary(admin.accessToken, library.id, { importPaths: [] });
       expect(response.importPaths).toEqual([]);
     });
 
     it('should fail if path does not exist', async () => {
       const pathToTest = `${testAssetDirInternal}/does/not/exist`;
 
-      const response = await apiUtils.validateLibrary(admin.accessToken, library.id, {
+      const response = await utils.validateLibrary(admin.accessToken, library.id, {
         importPaths: [pathToTest],
       });
 
@@ -439,7 +438,7 @@ describe('/library', () => {
     it('should fail if path is a file', async () => {
       const pathToTest = `${testAssetDirInternal}/albums/nature/el_torcal_rocks.jpg`;
 
-      const response = await apiUtils.validateLibrary(admin.accessToken, library.id, {
+      const response = await utils.validateLibrary(admin.accessToken, library.id, {
         importPaths: [pathToTest],
       });
 
diff --git a/e2e/src/api/specs/oauth.e2e-spec.ts b/e2e/src/api/specs/oauth.e2e-spec.ts
index 1324d3fa7f..81c4b452c1 100644
--- a/e2e/src/api/specs/oauth.e2e-spec.ts
+++ b/e2e/src/api/specs/oauth.e2e-spec.ts
@@ -1,16 +1,12 @@
 import { errorDto } from 'src/responses';
-import { apiUtils, app, dbUtils } from 'src/utils';
+import { app, utils } from 'src/utils';
 import request from 'supertest';
-import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
+import { beforeAll, describe, expect, it } from 'vitest';
 
 describe(`/oauth`, () => {
-  beforeAll(() => {
-    apiUtils.setup();
-  });
-
-  beforeEach(async () => {
-    await dbUtils.reset();
-    await apiUtils.adminSetup();
+  beforeAll(async () => {
+    await utils.resetDatabase();
+    await utils.adminSetup();
   });
 
   describe('POST /oauth/authorize', () => {
diff --git a/e2e/src/api/specs/partner.e2e-spec.ts b/e2e/src/api/specs/partner.e2e-spec.ts
index 2c88391bd4..b2fb7f4101 100644
--- a/e2e/src/api/specs/partner.e2e-spec.ts
+++ b/e2e/src/api/specs/partner.e2e-spec.ts
@@ -1,7 +1,7 @@
 import { LoginResponseDto, createPartner } from '@immich/sdk';
 import { createUserDto } from 'src/fixtures';
 import { errorDto } from 'src/responses';
-import { apiUtils, app, asBearerAuth, dbUtils } from 'src/utils';
+import { app, asBearerAuth, utils } from 'src/utils';
 import request from 'supertest';
 import { beforeAll, describe, expect, it } from 'vitest';
 
@@ -12,15 +12,14 @@ describe('/partner', () => {
   let user3: LoginResponseDto;
 
   beforeAll(async () => {
-    apiUtils.setup();
-    await dbUtils.reset();
+    await utils.resetDatabase();
 
-    admin = await apiUtils.adminSetup();
+    admin = await utils.adminSetup();
 
     [user1, user2, user3] = await Promise.all([
-      apiUtils.userSetup(admin.accessToken, createUserDto.user1),
-      apiUtils.userSetup(admin.accessToken, createUserDto.user2),
-      apiUtils.userSetup(admin.accessToken, createUserDto.user3),
+      utils.userSetup(admin.accessToken, createUserDto.user1),
+      utils.userSetup(admin.accessToken, createUserDto.user2),
+      utils.userSetup(admin.accessToken, createUserDto.user3),
     ]);
 
     await Promise.all([
diff --git a/e2e/src/api/specs/person.e2e-spec.ts b/e2e/src/api/specs/person.e2e-spec.ts
index 915c04f867..55cb982f9d 100644
--- a/e2e/src/api/specs/person.e2e-spec.ts
+++ b/e2e/src/api/specs/person.e2e-spec.ts
@@ -1,7 +1,7 @@
 import { LoginResponseDto, PersonResponseDto } from '@immich/sdk';
 import { uuidDto } from 'src/fixtures';
 import { errorDto } from 'src/responses';
-import { apiUtils, app, dbUtils } from 'src/utils';
+import { app, utils } from 'src/utils';
 import request from 'supertest';
 import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
 
@@ -12,36 +12,35 @@ describe('/activity', () => {
   let multipleAssetsPerson: PersonResponseDto;
 
   beforeAll(async () => {
-    apiUtils.setup();
-    await dbUtils.reset();
-    admin = await apiUtils.adminSetup();
+    await utils.resetDatabase();
+    admin = await utils.adminSetup();
   });
 
   beforeEach(async () => {
-    await dbUtils.reset(['person']);
+    await utils.resetDatabase(['person']);
 
     [visiblePerson, hiddenPerson, multipleAssetsPerson] = await Promise.all([
-      apiUtils.createPerson(admin.accessToken, {
+      utils.createPerson(admin.accessToken, {
         name: 'visible_person',
       }),
-      apiUtils.createPerson(admin.accessToken, {
+      utils.createPerson(admin.accessToken, {
         name: 'hidden_person',
         isHidden: true,
       }),
-      apiUtils.createPerson(admin.accessToken, {
+      utils.createPerson(admin.accessToken, {
         name: 'multiple_assets_person',
       }),
     ]);
 
-    const asset1 = await apiUtils.createAsset(admin.accessToken);
-    const asset2 = await apiUtils.createAsset(admin.accessToken);
+    const asset1 = await utils.createAsset(admin.accessToken);
+    const asset2 = await utils.createAsset(admin.accessToken);
 
     await Promise.all([
-      dbUtils.createFace({ assetId: asset1.id, personId: visiblePerson.id }),
-      dbUtils.createFace({ assetId: asset1.id, personId: hiddenPerson.id }),
-      dbUtils.createFace({ assetId: asset1.id, personId: multipleAssetsPerson.id }),
-      dbUtils.createFace({ assetId: asset1.id, personId: multipleAssetsPerson.id }),
-      dbUtils.createFace({ assetId: asset2.id, personId: multipleAssetsPerson.id }),
+      utils.createFace({ assetId: asset1.id, personId: visiblePerson.id }),
+      utils.createFace({ assetId: asset1.id, personId: hiddenPerson.id }),
+      utils.createFace({ assetId: asset1.id, personId: multipleAssetsPerson.id }),
+      utils.createFace({ assetId: asset1.id, personId: multipleAssetsPerson.id }),
+      utils.createFace({ assetId: asset2.id, personId: multipleAssetsPerson.id }),
     ]);
   });
 
@@ -194,7 +193,7 @@ describe('/activity', () => {
 
     it('should clear a date of birth', async () => {
       // TODO ironically this uses the update endpoint to create the person
-      const person = await apiUtils.createPerson(admin.accessToken, {
+      const person = await utils.createPerson(admin.accessToken, {
         birthDate: new Date('1990-01-01').toISOString(),
       });
 
diff --git a/e2e/src/api/specs/server-info.e2e-spec.ts b/e2e/src/api/specs/server-info.e2e-spec.ts
index b8262cb68a..5cfd6a8b98 100644
--- a/e2e/src/api/specs/server-info.e2e-spec.ts
+++ b/e2e/src/api/specs/server-info.e2e-spec.ts
@@ -1,7 +1,7 @@
 import { LoginResponseDto, getServerConfig } from '@immich/sdk';
 import { createUserDto } from 'src/fixtures';
 import { errorDto } from 'src/responses';
-import { apiUtils, app, dbUtils } from 'src/utils';
+import { app, utils } from 'src/utils';
 import request from 'supertest';
 import { beforeAll, describe, expect, it } from 'vitest';
 
@@ -10,10 +10,9 @@ describe('/server-info', () => {
   let nonAdmin: LoginResponseDto;
 
   beforeAll(async () => {
-    apiUtils.setup();
-    await dbUtils.reset();
-    admin = await apiUtils.adminSetup({ onboarding: false });
-    nonAdmin = await apiUtils.userSetup(admin.accessToken, createUserDto.user1);
+    await utils.resetDatabase();
+    admin = await utils.adminSetup({ onboarding: false });
+    nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1);
   });
 
   describe('GET /server-info', () => {
diff --git a/e2e/src/api/specs/shared-link.e2e-spec.ts b/e2e/src/api/specs/shared-link.e2e-spec.ts
index 7ff4bb6bf7..8b854eda00 100644
--- a/e2e/src/api/specs/shared-link.e2e-spec.ts
+++ b/e2e/src/api/specs/shared-link.e2e-spec.ts
@@ -9,7 +9,7 @@ import {
 } from '@immich/sdk';
 import { createUserDto, uuidDto } from 'src/fixtures';
 import { errorDto } from 'src/responses';
-import { apiUtils, app, asBearerAuth, dbUtils } from 'src/utils';
+import { app, asBearerAuth, utils } from 'src/utils';
 import request from 'supertest';
 import { beforeAll, describe, expect, it } from 'vitest';
 
@@ -30,20 +30,16 @@ describe('/shared-link', () => {
   let linkWithoutMetadata: SharedLinkResponseDto;
 
   beforeAll(async () => {
-    apiUtils.setup();
-    await dbUtils.reset();
+    await utils.resetDatabase();
 
-    admin = await apiUtils.adminSetup();
+    admin = await utils.adminSetup();
 
     [user1, user2] = await Promise.all([
-      apiUtils.userSetup(admin.accessToken, createUserDto.user1),
-      apiUtils.userSetup(admin.accessToken, createUserDto.user2),
+      utils.userSetup(admin.accessToken, createUserDto.user1),
+      utils.userSetup(admin.accessToken, createUserDto.user2),
     ]);
 
-    [asset1, asset2] = await Promise.all([
-      apiUtils.createAsset(user1.accessToken),
-      apiUtils.createAsset(user1.accessToken),
-    ]);
+    [asset1, asset2] = await Promise.all([utils.createAsset(user1.accessToken), utils.createAsset(user1.accessToken)]);
 
     [album, deletedAlbum, metadataAlbum] = await Promise.all([
       createAlbum({ createAlbumDto: { albumName: 'album' } }, { headers: asBearerAuth(user1.accessToken) }),
@@ -61,29 +57,29 @@ describe('/shared-link', () => {
 
     [linkWithDeletedAlbum, linkWithAlbum, linkWithAssets, linkWithPassword, linkWithMetadata, linkWithoutMetadata] =
       await Promise.all([
-        apiUtils.createSharedLink(user2.accessToken, {
+        utils.createSharedLink(user2.accessToken, {
           type: SharedLinkType.Album,
           albumId: deletedAlbum.id,
         }),
-        apiUtils.createSharedLink(user1.accessToken, {
+        utils.createSharedLink(user1.accessToken, {
           type: SharedLinkType.Album,
           albumId: album.id,
         }),
-        apiUtils.createSharedLink(user1.accessToken, {
+        utils.createSharedLink(user1.accessToken, {
           type: SharedLinkType.Individual,
           assetIds: [asset1.id],
         }),
-        apiUtils.createSharedLink(user1.accessToken, {
+        utils.createSharedLink(user1.accessToken, {
           type: SharedLinkType.Album,
           albumId: album.id,
           password: 'foo',
         }),
-        apiUtils.createSharedLink(user1.accessToken, {
+        utils.createSharedLink(user1.accessToken, {
           type: SharedLinkType.Album,
           albumId: metadataAlbum.id,
           showMetadata: true,
         }),
-        apiUtils.createSharedLink(user1.accessToken, {
+        utils.createSharedLink(user1.accessToken, {
           type: SharedLinkType.Album,
           albumId: metadataAlbum.id,
           showMetadata: false,
diff --git a/e2e/src/api/specs/system-config.e2e-spec.ts b/e2e/src/api/specs/system-config.e2e-spec.ts
index 6d8880d3fc..c223df4874 100644
--- a/e2e/src/api/specs/system-config.e2e-spec.ts
+++ b/e2e/src/api/specs/system-config.e2e-spec.ts
@@ -1,7 +1,7 @@
 import { LoginResponseDto } from '@immich/sdk';
 import { createUserDto } from 'src/fixtures';
 import { errorDto } from 'src/responses';
-import { apiUtils, app, dbUtils } from 'src/utils';
+import { app, utils } from 'src/utils';
 import request from 'supertest';
 import { beforeAll, describe, expect, it } from 'vitest';
 
@@ -10,10 +10,9 @@ describe('/system-config', () => {
   let nonAdmin: LoginResponseDto;
 
   beforeAll(async () => {
-    apiUtils.setup();
-    await dbUtils.reset();
-    admin = await apiUtils.adminSetup();
-    nonAdmin = await apiUtils.userSetup(admin.accessToken, createUserDto.user1);
+    await utils.resetDatabase();
+    admin = await utils.adminSetup();
+    nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1);
   });
 
   describe('GET /system-config/map/style.json', () => {
diff --git a/e2e/src/api/specs/trash.e2e-spec.ts b/e2e/src/api/specs/trash.e2e-spec.ts
index 60ed75f118..3e6c2f1fc6 100644
--- a/e2e/src/api/specs/trash.e2e-spec.ts
+++ b/e2e/src/api/specs/trash.e2e-spec.ts
@@ -1,7 +1,7 @@
 import { LoginResponseDto, getAllAssets } from '@immich/sdk';
 import { Socket } from 'socket.io-client';
 import { errorDto } from 'src/responses';
-import { apiUtils, app, asBearerAuth, dbUtils, wsUtils } from 'src/utils';
+import { app, asBearerAuth, utils } from 'src/utils';
 import request from 'supertest';
 import { afterAll, beforeAll, describe, expect, it } from 'vitest';
 
@@ -10,14 +10,13 @@ describe('/trash', () => {
   let ws: Socket;
 
   beforeAll(async () => {
-    apiUtils.setup();
-    await dbUtils.reset();
-    admin = await apiUtils.adminSetup({ onboarding: false });
-    ws = await wsUtils.connect(admin.accessToken);
+    await utils.resetDatabase();
+    admin = await utils.adminSetup({ onboarding: false });
+    ws = await utils.connectWebsocket(admin.accessToken);
   });
 
   afterAll(() => {
-    wsUtils.disconnect(ws);
+    utils.disconnectWebsocket(ws);
   });
 
   describe('POST /trash/empty', () => {
@@ -29,8 +28,8 @@ describe('/trash', () => {
     });
 
     it('should empty the trash', async () => {
-      const { id: assetId } = await apiUtils.createAsset(admin.accessToken);
-      await apiUtils.deleteAssets(admin.accessToken, [assetId]);
+      const { id: assetId } = await utils.createAsset(admin.accessToken);
+      await utils.deleteAssets(admin.accessToken, [assetId]);
 
       const before = await getAllAssets({}, { headers: asBearerAuth(admin.accessToken) });
 
@@ -39,7 +38,7 @@ describe('/trash', () => {
       const { status } = await request(app).post('/trash/empty').set('Authorization', `Bearer ${admin.accessToken}`);
       expect(status).toBe(204);
 
-      await wsUtils.waitForEvent({ event: 'delete', assetId });
+      await utils.waitForWebsocketEvent({ event: 'delete', assetId });
 
       const after = await getAllAssets({}, { headers: asBearerAuth(admin.accessToken) });
       expect(after.length).toBe(0);
@@ -55,16 +54,16 @@ describe('/trash', () => {
     });
 
     it('should restore all trashed assets', async () => {
-      const { id: assetId } = await apiUtils.createAsset(admin.accessToken);
-      await apiUtils.deleteAssets(admin.accessToken, [assetId]);
+      const { id: assetId } = await utils.createAsset(admin.accessToken);
+      await utils.deleteAssets(admin.accessToken, [assetId]);
 
-      const before = await apiUtils.getAssetInfo(admin.accessToken, assetId);
+      const before = await utils.getAssetInfo(admin.accessToken, assetId);
       expect(before.isTrashed).toBe(true);
 
       const { status } = await request(app).post('/trash/restore').set('Authorization', `Bearer ${admin.accessToken}`);
       expect(status).toBe(204);
 
-      const after = await apiUtils.getAssetInfo(admin.accessToken, assetId);
+      const after = await utils.getAssetInfo(admin.accessToken, assetId);
       expect(after.isTrashed).toBe(false);
     });
   });
@@ -78,10 +77,10 @@ describe('/trash', () => {
     });
 
     it('should restore a trashed asset by id', async () => {
-      const { id: assetId } = await apiUtils.createAsset(admin.accessToken);
-      await apiUtils.deleteAssets(admin.accessToken, [assetId]);
+      const { id: assetId } = await utils.createAsset(admin.accessToken);
+      await utils.deleteAssets(admin.accessToken, [assetId]);
 
-      const before = await apiUtils.getAssetInfo(admin.accessToken, assetId);
+      const before = await utils.getAssetInfo(admin.accessToken, assetId);
       expect(before.isTrashed).toBe(true);
 
       const { status } = await request(app)
@@ -90,7 +89,7 @@ describe('/trash', () => {
         .send({ ids: [assetId] });
       expect(status).toBe(204);
 
-      const after = await apiUtils.getAssetInfo(admin.accessToken, assetId);
+      const after = await utils.getAssetInfo(admin.accessToken, assetId);
       expect(after.isTrashed).toBe(false);
     });
   });
diff --git a/e2e/src/api/specs/user.e2e-spec.ts b/e2e/src/api/specs/user.e2e-spec.ts
index e47e1d531c..d448a605cd 100644
--- a/e2e/src/api/specs/user.e2e-spec.ts
+++ b/e2e/src/api/specs/user.e2e-spec.ts
@@ -1,7 +1,7 @@
 import { LoginResponseDto, deleteUser, getUserById } from '@immich/sdk';
 import { createUserDto, userDto } from 'src/fixtures';
 import { errorDto } from 'src/responses';
-import { apiUtils, app, asBearerAuth, dbUtils } from 'src/utils';
+import { app, asBearerAuth, utils } from 'src/utils';
 import request from 'supertest';
 import { beforeAll, describe, expect, it } from 'vitest';
 
@@ -12,14 +12,13 @@ describe('/server-info', () => {
   let nonAdmin: LoginResponseDto;
 
   beforeAll(async () => {
-    apiUtils.setup();
-    await dbUtils.reset();
-    admin = await apiUtils.adminSetup({ onboarding: false });
+    await utils.resetDatabase();
+    admin = await utils.adminSetup({ onboarding: false });
 
     [deletedUser, nonAdmin, userToDelete] = await Promise.all([
-      apiUtils.userSetup(admin.accessToken, createUserDto.user1),
-      apiUtils.userSetup(admin.accessToken, createUserDto.user2),
-      apiUtils.userSetup(admin.accessToken, createUserDto.user3),
+      utils.userSetup(admin.accessToken, createUserDto.user1),
+      utils.userSetup(admin.accessToken, createUserDto.user2),
+      utils.userSetup(admin.accessToken, createUserDto.user3),
     ]);
 
     await deleteUser({ id: deletedUser.userId }, { headers: asBearerAuth(admin.accessToken) });
diff --git a/e2e/src/cli/specs/login.e2e-spec.ts b/e2e/src/cli/specs/login.e2e-spec.ts
index aa27bec63e..61702769ca 100644
--- a/e2e/src/cli/specs/login.e2e-spec.ts
+++ b/e2e/src/cli/specs/login.e2e-spec.ts
@@ -1,14 +1,10 @@
 import { stat } from 'node:fs/promises';
-import { apiUtils, app, dbUtils, immichCli } from 'src/utils';
-import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
+import { app, immichCli, utils } from 'src/utils';
+import { beforeEach, describe, expect, it } from 'vitest';
 
 describe(`immich login-key`, () => {
-  beforeAll(() => {
-    apiUtils.setup();
-  });
-
   beforeEach(async () => {
-    await dbUtils.reset();
+    await utils.resetDatabase();
   });
 
   it('should require a url', async () => {
@@ -30,8 +26,8 @@ describe(`immich login-key`, () => {
   });
 
   it('should login and save auth.yml with 600', async () => {
-    const admin = await apiUtils.adminSetup();
-    const key = await apiUtils.createApiKey(admin.accessToken);
+    const admin = await utils.adminSetup();
+    const key = await utils.createApiKey(admin.accessToken);
     const { stdout, stderr, exitCode } = await immichCli(['login-key', app, `${key.secret}`]);
     expect(stdout.split('\n')).toEqual([
       'Logging in to http://127.0.0.1:2283/api',
@@ -47,8 +43,8 @@ describe(`immich login-key`, () => {
   });
 
   it('should login without /api in the url', async () => {
-    const admin = await apiUtils.adminSetup();
-    const key = await apiUtils.createApiKey(admin.accessToken);
+    const admin = await utils.adminSetup();
+    const key = await utils.createApiKey(admin.accessToken);
     const { stdout, stderr, exitCode } = await immichCli(['login-key', app.replaceAll('/api', ''), `${key.secret}`]);
     expect(stdout.split('\n')).toEqual([
       'Logging in to http://127.0.0.1:2283',
diff --git a/e2e/src/cli/specs/server-info.e2e-spec.ts b/e2e/src/cli/specs/server-info.e2e-spec.ts
index 038a2c2ca0..6efe002b86 100644
--- a/e2e/src/cli/specs/server-info.e2e-spec.ts
+++ b/e2e/src/cli/specs/server-info.e2e-spec.ts
@@ -1,11 +1,10 @@
-import { apiUtils, cliUtils, dbUtils, immichCli } from 'src/utils';
+import { immichCli, utils } from 'src/utils';
 import { beforeAll, describe, expect, it } from 'vitest';
 
 describe(`immich server-info`, () => {
   beforeAll(async () => {
-    apiUtils.setup();
-    await dbUtils.reset();
-    await cliUtils.login();
+    await utils.resetDatabase();
+    await utils.cliLogin();
   });
 
   it('should return the server info', async () => {
diff --git a/e2e/src/cli/specs/upload.e2e-spec.ts b/e2e/src/cli/specs/upload.e2e-spec.ts
index bda625241e..27362ef237 100644
--- a/e2e/src/cli/specs/upload.e2e-spec.ts
+++ b/e2e/src/cli/specs/upload.e2e-spec.ts
@@ -1,19 +1,18 @@
 import { getAllAlbums, getAllAssets } from '@immich/sdk';
 import { mkdir, readdir, rm, symlink } from 'node:fs/promises';
-import { apiUtils, asKeyAuth, cliUtils, dbUtils, immichCli, testAssetDir } from 'src/utils';
+import { asKeyAuth, immichCli, testAssetDir, utils } from 'src/utils';
 import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
 
 describe(`immich upload`, () => {
   let key: string;
 
   beforeAll(async () => {
-    apiUtils.setup();
-    await dbUtils.reset();
-    key = await cliUtils.login();
+    await utils.resetDatabase();
+    key = await utils.cliLogin();
   });
 
   beforeEach(async () => {
-    await dbUtils.reset(['assets', 'albums']);
+    await utils.resetDatabase(['assets', 'albums']);
   });
 
   describe('immich upload --recursive', () => {
diff --git a/e2e/src/cli/specs/version.e2e-spec.ts b/e2e/src/cli/specs/version.e2e-spec.ts
index e94ccf214f..56a0d8b0b1 100644
--- a/e2e/src/cli/specs/version.e2e-spec.ts
+++ b/e2e/src/cli/specs/version.e2e-spec.ts
@@ -1,14 +1,10 @@
 import { readFileSync } from 'node:fs';
-import { apiUtils, immichCli } from 'src/utils';
-import { beforeAll, describe, expect, it } from 'vitest';
+import { immichCli } from 'src/utils';
+import { describe, expect, it } from 'vitest';
 
 const pkg = JSON.parse(readFileSync('../cli/package.json', 'utf8'));
 
 describe(`immich --version`, () => {
-  beforeAll(() => {
-    apiUtils.setup();
-  });
-
   describe('immich --version', () => {
     it('should print the cli version', async () => {
       const { stdout, stderr, exitCode } = await immichCli(['--version']);
diff --git a/e2e/src/setup.ts b/e2e/src/setup.ts
index e0ff443566..a3d96ac17f 100644
--- a/e2e/src/setup.ts
+++ b/e2e/src/setup.ts
@@ -1,8 +1,16 @@
 import { exec, spawn } from 'node:child_process';
+import { setTimeout } from 'node:timers';
 
 export default async () => {
   let _resolve: () => unknown;
-  const ready = new Promise<void>((resolve) => (_resolve = resolve));
+  let _reject: (error: Error) => unknown;
+
+  const ready = new Promise<void>((resolve, reject) => {
+    _resolve = resolve;
+    _reject = reject;
+  });
+
+  const timeout = setTimeout(() => _reject(new Error('Timeout starting e2e environment')), 60_000);
 
   const child = spawn('docker', ['compose', 'up'], { stdio: 'pipe' });
 
@@ -17,6 +25,7 @@ export default async () => {
   child.stderr.on('data', (data) => console.log(data.toString()));
 
   await ready;
+  clearTimeout(timeout);
 
   return async () => {
     await new Promise<void>((resolve) => exec('docker compose down', () => resolve()));
diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts
index 9be730c7e1..5547a2c128 100644
--- a/e2e/src/utils.ts
+++ b/e2e/src/utils.ts
@@ -26,7 +26,7 @@ import {
 import { BrowserContext } from '@playwright/test';
 import { exec, spawn } from 'node:child_process';
 import { createHash } from 'node:crypto';
-import { access } from 'node:fs/promises';
+import { existsSync } from 'node:fs';
 import { tmpdir } from 'node:os';
 import path from 'node:path';
 import { promisify } from 'node:util';
@@ -36,79 +36,71 @@ import { loginDto, signupDto } from 'src/fixtures';
 import { makeRandomImage } from 'src/generators';
 import request from 'supertest';
 
-const execPromise = promisify(exec);
+type CliResponse = { stdout: string; stderr: string; exitCode: number | null };
+type EventType = 'upload' | 'delete';
+type WaitOptions = { event: EventType; assetId: string; timeout?: number };
+type AdminSetupOptions = { onboarding?: boolean };
+type AssetData = { bytes?: Buffer; filename: string };
 
-export const app = 'http://127.0.0.1:2283/api';
-
-const directoryExists = (directory: string) =>
-  access(directory)
-    .then(() => true)
-    .catch(() => false);
+const dbUrl = 'postgres://postgres:postgres@127.0.0.1:5433/immich';
+const baseUrl = 'http://127.0.0.1:2283';
 
+export const app = `${baseUrl}/api`;
 // TODO move test assets into e2e/assets
 export const testAssetDir = path.resolve(`./../server/test/assets/`);
 export const testAssetDirInternal = '/data/assets';
 export const tempDir = tmpdir();
-
-const serverContainerName = 'immich-e2e-server';
-const mediaDir = '/usr/src/app/upload';
-const dirs = [
-  `"${mediaDir}/thumbs"`,
-  `"${mediaDir}/upload"`,
-  `"${mediaDir}/library"`,
-  `"${mediaDir}/encoded-video"`,
-].join(' ');
-
-if (!(await directoryExists(`${testAssetDir}/albums`))) {
-  throw new Error(
-    `Test assets not found. Please checkout https://github.com/immich-app/test-assets into ${testAssetDir} before testing`,
-  );
-}
-
-export const asBearerAuth = (accessToken: string) => ({
-  Authorization: `Bearer ${accessToken}`,
-});
-
+export const asBearerAuth = (accessToken: string) => ({ Authorization: `Bearer ${accessToken}` });
 export const asKeyAuth = (key: string) => ({ 'x-api-key': key });
+export const immichCli = async (args: string[]) => {
+  let _resolve: (value: CliResponse) => void;
+  const deferred = new Promise<CliResponse>((resolve) => (_resolve = resolve));
+  const _args = ['node_modules/.bin/immich', '-d', `/${tempDir}/immich/`, ...args];
+  const child = spawn('node', _args, {
+    stdio: 'pipe',
+  });
+
+  let stdout = '';
+  let stderr = '';
+
+  child.stdout.on('data', (data) => (stdout += data.toString()));
+  child.stderr.on('data', (data) => (stderr += data.toString()));
+  child.on('exit', (exitCode) => {
+    _resolve({
+      stdout: stdout.trim(),
+      stderr: stderr.trim(),
+      exitCode,
+    });
+  });
+
+  return deferred;
+};
 
 let client: pg.Client | null = null;
 
-export const fileUtils = {
-  reset: async () => {
-    await execPromise(`docker exec -i "${serverContainerName}" /bin/bash -c "rm -rf ${dirs} && mkdir ${dirs}"`);
-  },
-  unzip: async (input: string, output: string) => {
-    await execPromise(`unzip -o -d "${output}" "${input}"`);
-  },
-  sha1: (bytes: Buffer) => createHash('sha1').update(bytes).digest('base64'),
+const events: Record<EventType, Set<string>> = {
+  upload: new Set<string>(),
+  delete: new Set<string>(),
 };
 
-export const dbUtils = {
-  createFace: async ({ assetId, personId }: { assetId: string; personId: string }) => {
-    if (!client) {
-      return;
-    }
+const callbacks: Record<string, () => void> = {};
 
-    const vector = Array.from({ length: 512 }, Math.random);
-    const embedding = `[${vector.join(',')}]`;
+const execPromise = promisify(exec);
 
-    await client.query('INSERT INTO asset_faces ("assetId", "personId", "embedding") VALUES ($1, $2, $3)', [
-      assetId,
-      personId,
-      embedding,
-    ]);
-  },
-  setPersonThumbnail: async (personId: string) => {
-    if (!client) {
-      return;
-    }
+const onEvent = ({ event, assetId }: { event: EventType; assetId: string }) => {
+  events[event].add(assetId);
+  const callback = callbacks[assetId];
+  if (callback) {
+    callback();
+    delete callbacks[assetId];
+  }
+};
 
-    await client.query(`UPDATE "person" set "thumbnailPath" = '/my/awesome/thumbnail.jpg' where "id" = $1`, [personId]);
-  },
-  reset: async (tables?: string[]) => {
+export const utils = {
+  resetDatabase: async (tables?: string[]) => {
     try {
       if (!client) {
-        client = new pg.Client('postgres://postgres:postgres@127.0.0.1:5433/immich');
+        client = new pg.Client(dbUrl);
         await client.connect();
       }
 
@@ -134,83 +126,27 @@ export const dbUtils = {
       throw error;
     }
   },
-  teardown: async () => {
-    try {
-      if (client) {
-        await client.end();
-        client = null;
-      }
-    } catch (error) {
-      console.error('Failed to teardown database', error);
-      throw error;
-    }
+
+  resetFilesystem: async () => {
+    const mediaInternal = '/usr/src/app/upload';
+    const dirs = [
+      `"${mediaInternal}/thumbs"`,
+      `"${mediaInternal}/upload"`,
+      `"${mediaInternal}/library"`,
+      `"${mediaInternal}/encoded-video"`,
+    ].join(' ');
+
+    await execPromise(`docker exec -i "immich-e2e-server" /bin/bash -c "rm -rf ${dirs} && mkdir ${dirs}"`);
   },
-};
-export interface CliResponse {
-  stdout: string;
-  stderr: string;
-  exitCode: number | null;
-}
 
-export const immichCli = async (args: string[]) => {
-  let _resolve: (value: CliResponse) => void;
-  const deferred = new Promise<CliResponse>((resolve) => (_resolve = resolve));
-  const _args = ['node_modules/.bin/immich', '-d', `/${tempDir}/immich/`, ...args];
-  const child = spawn('node', _args, {
-    stdio: 'pipe',
-  });
+  unzip: async (input: string, output: string) => {
+    await execPromise(`unzip -o -d "${output}" "${input}"`);
+  },
 
-  let stdout = '';
-  let stderr = '';
+  sha1: (bytes: Buffer) => createHash('sha1').update(bytes).digest('base64'),
 
-  child.stdout.on('data', (data) => (stdout += data.toString()));
-  child.stderr.on('data', (data) => (stderr += data.toString()));
-  child.on('exit', (exitCode) => {
-    _resolve({
-      stdout: stdout.trim(),
-      stderr: stderr.trim(),
-      exitCode,
-    });
-  });
-
-  return deferred;
-};
-
-export interface AdminSetupOptions {
-  onboarding?: boolean;
-}
-
-export enum SocketEvent {
-  UPLOAD = 'upload',
-  DELETE = 'delete',
-}
-
-export type EventType = 'upload' | 'delete';
-export interface WaitOptions {
-  event: EventType;
-  assetId: string;
-  timeout?: number;
-}
-
-const events: Record<EventType, Set<string>> = {
-  upload: new Set<string>(),
-  delete: new Set<string>(),
-};
-
-const callbacks: Record<string, () => void> = {};
-
-const onEvent = ({ event, assetId }: { event: EventType; assetId: string }) => {
-  events[event].add(assetId);
-  const callback = callbacks[assetId];
-  if (callback) {
-    callback();
-    delete callbacks[assetId];
-  }
-};
-
-export const wsUtils = {
-  connect: async (accessToken: string) => {
-    const websocket = io('http://127.0.0.1:2283', {
+  connectWebsocket: async (accessToken: string) => {
+    const websocket = io(baseUrl, {
       path: '/api/socket.io',
       transports: ['websocket'],
       extraHeaders: { Authorization: `Bearer ${accessToken}` },
@@ -226,7 +162,8 @@ export const wsUtils = {
         .connect();
     });
   },
-  disconnect: (ws: Socket) => {
+
+  disconnectWebsocket: (ws: Socket) => {
     if (ws?.connected) {
       ws.disconnect();
     }
@@ -235,14 +172,15 @@ export const wsUtils = {
       set.clear();
     }
   },
-  waitForEvent: async ({ event, assetId, timeout: ms }: WaitOptions): Promise<void> => {
+
+  waitForWebsocketEvent: async ({ event, assetId, timeout: ms }: WaitOptions): Promise<void> => {
     const set = events[event];
     if (set.has(assetId)) {
       return;
     }
 
     return new Promise<void>((resolve, reject) => {
-      const timeout = setTimeout(() => reject(new Error(`Timed out waiting for ${event} event`)), ms || 5000);
+      const timeout = setTimeout(() => reject(new Error(`Timed out waiting for ${event} event`)), ms || 10_000);
 
       callbacks[assetId] = () => {
         clearTimeout(timeout);
@@ -250,12 +188,8 @@ export const wsUtils = {
       };
     });
   },
-};
 
-type AssetData = { bytes?: Buffer; filename: string };
-
-export const apiUtils = {
-  setup: () => {
+  setApiEndpoint: () => {
     defaults.baseUrl = app;
   },
 
@@ -269,17 +203,21 @@ export const apiUtils = {
     }
     return response;
   },
+
   userSetup: async (accessToken: string, dto: CreateUserDto) => {
     await createUser({ createUserDto: dto }, { headers: asBearerAuth(accessToken) });
     return login({
       loginCredentialDto: { email: dto.email, password: dto.password },
     });
   },
+
   createApiKey: (accessToken: string) => {
     return createApiKey({ apiKeyCreateDto: { name: 'e2e' } }, { headers: asBearerAuth(accessToken) });
   },
+
   createAlbum: (accessToken: string, dto: CreateAlbumDto) =>
     createAlbum({ createAlbumDto: dto }, { headers: asBearerAuth(accessToken) }),
+
   createAsset: async (
     accessToken: string,
     dto?: Partial<Omit<CreateAssetDto, 'assetData'>> & { assetData?: AssetData },
@@ -308,13 +246,16 @@ export const apiUtils = {
 
     return body as AssetFileUploadResponseDto;
   },
+
   getAssetInfo: (accessToken: string, id: string) => getAssetInfo({ id }, { headers: asBearerAuth(accessToken) }),
+
   deleteAssets: (accessToken: string, ids: string[]) =>
     deleteAssets({ assetBulkDeleteDto: { ids } }, { headers: asBearerAuth(accessToken) }),
+
   createPerson: async (accessToken: string, dto?: PersonUpdateDto) => {
     // TODO fix createPerson to accept a body
     const person = await createPerson({ headers: asBearerAuth(accessToken) });
-    await dbUtils.setPersonThumbnail(person.id);
+    await utils.setPersonThumbnail(person.id);
 
     if (!dto) {
       return person;
@@ -322,24 +263,39 @@ export const apiUtils = {
 
     return updatePerson({ id: person.id, personUpdateDto: dto }, { headers: asBearerAuth(accessToken) });
   },
+
+  createFace: async ({ assetId, personId }: { assetId: string; personId: string }) => {
+    if (!client) {
+      return;
+    }
+
+    const vector = Array.from({ length: 512 }, Math.random);
+    const embedding = `[${vector.join(',')}]`;
+
+    await client.query('INSERT INTO asset_faces ("assetId", "personId", "embedding") VALUES ($1, $2, $3)', [
+      assetId,
+      personId,
+      embedding,
+    ]);
+  },
+
+  setPersonThumbnail: async (personId: string) => {
+    if (!client) {
+      return;
+    }
+
+    await client.query(`UPDATE "person" set "thumbnailPath" = '/my/awesome/thumbnail.jpg' where "id" = $1`, [personId]);
+  },
+
   createSharedLink: (accessToken: string, dto: SharedLinkCreateDto) =>
     createSharedLink({ sharedLinkCreateDto: dto }, { headers: asBearerAuth(accessToken) }),
+
   createLibrary: (accessToken: string, dto: CreateLibraryDto) =>
     createLibrary({ createLibraryDto: dto }, { headers: asBearerAuth(accessToken) }),
+
   validateLibrary: (accessToken: string, id: string, dto: ValidateLibraryDto) =>
     validate({ id, validateLibraryDto: dto }, { headers: asBearerAuth(accessToken) }),
-};
 
-export const cliUtils = {
-  login: async () => {
-    const admin = await apiUtils.adminSetup();
-    const key = await apiUtils.createApiKey(admin.accessToken);
-    await immichCli(['login-key', app, `${key.secret}`]);
-    return key.secret;
-  },
-};
-
-export const webUtils = {
   setAuthCookies: async (context: BrowserContext, accessToken: string) =>
     await context.addCookies([
       {
@@ -373,4 +329,19 @@ export const webUtils = {
         sameSite: 'Lax',
       },
     ]),
+
+  cliLogin: async () => {
+    const admin = await utils.adminSetup();
+    const key = await utils.createApiKey(admin.accessToken);
+    await immichCli(['login-key', app, `${key.secret}`]);
+    return key.secret;
+  },
 };
+
+utils.setApiEndpoint();
+
+if (!existsSync(`${testAssetDir}/albums`)) {
+  throw new Error(
+    `Test assets not found. Please checkout https://github.com/immich-app/test-assets into ${testAssetDir} before testing`,
+  );
+}
diff --git a/e2e/src/web/specs/auth.e2e-spec.ts b/e2e/src/web/specs/auth.e2e-spec.ts
index 23210205a3..73d62f1b10 100644
--- a/e2e/src/web/specs/auth.e2e-spec.ts
+++ b/e2e/src/web/specs/auth.e2e-spec.ts
@@ -1,17 +1,13 @@
 import { expect, test } from '@playwright/test';
-import { apiUtils, dbUtils, webUtils } from 'src/utils';
+import { utils } from 'src/utils';
 
 test.describe('Registration', () => {
   test.beforeAll(() => {
-    apiUtils.setup();
+    utils.setApiEndpoint();
   });
 
   test.beforeEach(async () => {
-    await dbUtils.reset();
-  });
-
-  test.afterAll(async () => {
-    await dbUtils.teardown();
+    await utils.resetDatabase();
   });
 
   test('admin registration', async ({ page }) => {
@@ -45,8 +41,8 @@ test.describe('Registration', () => {
   });
 
   test('user registration', async ({ context, page }) => {
-    const admin = await apiUtils.adminSetup();
-    await webUtils.setAuthCookies(context, admin.accessToken);
+    const admin = await utils.adminSetup();
+    await utils.setAuthCookies(context, admin.accessToken);
 
     // create user
     await page.goto('/admin/user-management');
diff --git a/e2e/src/web/specs/shared-link.e2e-spec.ts b/e2e/src/web/specs/shared-link.e2e-spec.ts
index 6b2dbad95c..3540ed72e2 100644
--- a/e2e/src/web/specs/shared-link.e2e-spec.ts
+++ b/e2e/src/web/specs/shared-link.e2e-spec.ts
@@ -7,7 +7,7 @@ import {
   createAlbum,
 } from '@immich/sdk';
 import { test } from '@playwright/test';
-import { apiUtils, asBearerAuth, dbUtils } from 'src/utils';
+import { asBearerAuth, utils } from 'src/utils';
 
 test.describe('Shared Links', () => {
   let admin: LoginResponseDto;
@@ -17,10 +17,10 @@ test.describe('Shared Links', () => {
   let sharedLinkPassword: SharedLinkResponseDto;
 
   test.beforeAll(async () => {
-    apiUtils.setup();
-    await dbUtils.reset();
-    admin = await apiUtils.adminSetup();
-    asset = await apiUtils.createAsset(admin.accessToken);
+    utils.setApiEndpoint();
+    await utils.resetDatabase();
+    admin = await utils.adminSetup();
+    asset = await utils.createAsset(admin.accessToken);
     album = await createAlbum(
       {
         createAlbumDto: {
@@ -30,21 +30,17 @@ test.describe('Shared Links', () => {
       },
       { headers: asBearerAuth(admin.accessToken) },
     );
-    sharedLink = await apiUtils.createSharedLink(admin.accessToken, {
+    sharedLink = await utils.createSharedLink(admin.accessToken, {
       type: SharedLinkType.Album,
       albumId: album.id,
     });
-    sharedLinkPassword = await apiUtils.createSharedLink(admin.accessToken, {
+    sharedLinkPassword = await utils.createSharedLink(admin.accessToken, {
       type: SharedLinkType.Album,
       albumId: album.id,
       password: 'test-password',
     });
   });
 
-  test.afterAll(async () => {
-    await dbUtils.teardown();
-  });
-
   test('download from a shared link', async ({ page }) => {
     await page.goto(`/share/${sharedLink.key}`);
     await page.getByRole('heading', { name: 'Test Album' }).waitFor();
diff --git a/e2e/vitest.config.ts b/e2e/vitest.config.ts
index b8cc098ddd..d7dcde4c38 100644
--- a/e2e/vitest.config.ts
+++ b/e2e/vitest.config.ts
@@ -12,6 +12,7 @@ export default defineConfig({
   test: {
     include: ['src/{api,cli}/specs/*.e2e-spec.ts'],
     globalSetup,
+    testTimeout: 10_000,
     poolOptions: {
       threads: {
         singleThread: true,