From dba365634a5a54d312f3521a220ae9a02b0051bb Mon Sep 17 00:00:00 2001
From: Jason Rasmussen <jrasm91@gmail.com>
Date: Mon, 15 Apr 2024 23:05:08 -0400
Subject: [PATCH] chore(server): cleanup library watching (#8835)

chore: clean up library watching
---
 .github/workflows/test.yml                    |  13 -
 Makefile                                      |   3 -
 docs/docs/developer/testing.md                |  23 +-
 e2e/src/api/specs/library.e2e-spec.ts         |  39 +--
 e2e/src/utils.ts                              |  22 +-
 server/e2e/client/asset-api.ts                |  10 -
 server/e2e/client/auth-api.ts                 |  23 --
 server/e2e/client/index.ts                    |   9 -
 server/e2e/client/library-api.ts              |  33 --
 server/e2e/docker-compose.server-e2e.yml      |  33 --
 .../config/library-watcher-e2e-config.json    |  17 -
 server/e2e/jobs/immich-e2e-config.json        |  12 -
 server/e2e/jobs/jest-e2e.json                 |  22 --
 server/e2e/jobs/setup.ts                      |  42 ---
 .../jobs/specs/library-watcher.e2e-spec.ts    | 231 -------------
 server/package-lock.json                      | 322 ------------------
 server/package.json                           |   3 -
 server/src/interfaces/storage.interface.ts    |   8 -
 server/src/repositories/storage.repository.ts |  11 +-
 server/src/services/library.service.spec.ts   |  71 +---
 server/src/services/library.service.ts        |  19 +-
 .../repositories/storage.repository.mock.ts   |  10 +-
 server/test/utils.ts                          | 168 ---------
 23 files changed, 56 insertions(+), 1088 deletions(-)
 delete mode 100644 server/e2e/client/asset-api.ts
 delete mode 100644 server/e2e/client/auth-api.ts
 delete mode 100644 server/e2e/client/index.ts
 delete mode 100644 server/e2e/client/library-api.ts
 delete mode 100644 server/e2e/docker-compose.server-e2e.yml
 delete mode 100644 server/e2e/jobs/config/library-watcher-e2e-config.json
 delete mode 100644 server/e2e/jobs/immich-e2e-config.json
 delete mode 100644 server/e2e/jobs/jest-e2e.json
 delete mode 100644 server/e2e/jobs/setup.ts
 delete mode 100644 server/e2e/jobs/specs/library-watcher.e2e-spec.ts
 delete mode 100644 server/test/utils.ts

diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 6a5df111d7..d74c961393 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -10,19 +10,6 @@ concurrency:
   cancel-in-progress: true
 
 jobs:
-  server-e2e-jobs:
-    name: Server (e2e-jobs)
-    runs-on: ubuntu-latest
-
-    steps:
-      - name: Checkout code
-        uses: actions/checkout@v4
-        with:
-          submodules: 'recursive'
-
-      - name: Run e2e tests
-        run: make server-e2e-jobs
-
   doc-tests:
     name: Docs
     runs-on: ubuntu-latest
diff --git a/Makefile b/Makefile
index 55875e732b..ede70237fe 100644
--- a/Makefile
+++ b/Makefile
@@ -16,9 +16,6 @@ stage:
 pull-stage:
 	docker compose -f ./docker/docker-compose.staging.yml pull
 
-server-e2e-jobs:
-	docker compose -f ./server/e2e/docker-compose.server-e2e.yml up --renew-anon-volumes --abort-on-container-exit --exit-code-from immich-server --remove-orphans --build
-
 .PHONY: e2e
 e2e:
 	docker compose -f ./e2e/docker-compose.yml up --build -V --remove-orphans
diff --git a/docs/docs/developer/testing.md b/docs/docs/developer/testing.md
index de080fbb52..fecb58f592 100644
--- a/docs/docs/developer/testing.md
+++ b/docs/docs/developer/testing.md
@@ -8,15 +8,24 @@ Unit are run by calling `npm run test` from the `server` directory.
 
 ### End to end tests
 
-The backend has two end-to-end test suites that can be called with the following two commands from the project root directory:
+The e2e tests can be run by first starting up a test production environment via:
 
-- `make server-e2e-api`
-- `make server-e2e-jobs`
+```bash
+make e2e
+```
 
-#### API (e2e)
+Once the test environment is running, the e2e tests can be run via:
 
-The API e2e tests spin up a test database and execute http requests against the server, validating the expected response codes and functionality for API endpoints.
+```bash
+cd e2e/
+npm test
+```
 
-#### Jobs (e2e)
+The tests check various things including:
 
-The Jobs e2e tests spin up a docker test environment where thumbnail generation, library scanning, and other _job_ workflows are validated.
+- Authentication and authorization
+- Query param, body, and url validation
+- Response codes
+- Thumbnail generation
+- Metadata extraction
+- Library scanning
diff --git a/e2e/src/api/specs/library.e2e-spec.ts b/e2e/src/api/specs/library.e2e-spec.ts
index 587f760c20..18becec770 100644
--- a/e2e/src/api/specs/library.e2e-spec.ts
+++ b/e2e/src/api/specs/library.e2e-spec.ts
@@ -27,6 +27,7 @@ describe('/library', () => {
   beforeAll(async () => {
     await utils.resetDatabase();
     admin = await utils.adminSetup();
+    await utils.resetAdminConfig(admin.accessToken);
     user = await utils.userSetup(admin.accessToken, userDto.user1);
     library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, type: LibraryType.External });
     websocket = await utils.connectWebsocket(admin.accessToken);
@@ -36,7 +37,7 @@ describe('/library', () => {
 
   afterAll(() => {
     utils.disconnectWebsocket(websocket);
-    utils.deleteTempFolder();
+    utils.resetTempFolder();
   });
 
   beforeEach(() => {
@@ -816,40 +817,4 @@ describe('/library', () => {
       expect(existsSync(`${testAssetDir}/temp/directoryB/assetB.png`)).toBe(true);
     });
   });
-
-  // describe('Watching', () => {
-  //   beforeAll(async () => {
-  //     const config = await getConfigDefaults({ headers: asBearerAuth(admin.accessToken) });
-  //     await updateConfig(
-  //       { systemConfigDto: { ...config, library: { ...config.library, watch: { enabled: true } } } },
-  //       { headers: asBearerAuth(admin.accessToken) },
-  //     );
-  //   });
-
-  //   afterAll(async () => {
-  //     const defaultConfig = await getConfigDefaults({ headers: asBearerAuth(admin.accessToken) });
-  //     await updateConfig({ systemConfigDto: defaultConfig }, { headers: asBearerAuth(admin.accessToken) });
-  //     rmSync(`${testAssetDir}/temp/watch`, { recursive: true });
-  //   });
-
-  //   describe('Single import path', () => {
-  //     let library: LibraryResponseDto;
-  //     beforeEach(async () => {
-  //       library = await utils.createLibrary(admin.accessToken, {
-  //         ownerId: admin.userId,
-  //         type: LibraryType.External,
-  //         importPaths: [`${testAssetDirInternal}/temp`],
-  //       });
-  //     });
-
-  //     it('should import a new file', async () => {
-  //       utils.createImageFile(`${testAssetDir}/temp/watch/assetA.png`);
-
-  //       await utils.waitForWebsocketEvent({ event: 'assetUpload', total: 1 });
-
-  //       const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id });
-  //       expect(assets.count).toEqual(3);
-  //     });
-  //   });
-  // });
 });
diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts
index 0495acbbfc..a46653eb11 100644
--- a/e2e/src/utils.ts
+++ b/e2e/src/utils.ts
@@ -21,10 +21,12 @@ import {
   getAllAssets,
   getAllJobsStatus,
   getAssetInfo,
+  getConfigDefaults,
   login,
   searchMetadata,
   setAdminOnboarding,
   signUpAdmin,
+  updateConfig,
   validate,
 } from '@immich/sdk';
 import { BrowserContext } from '@playwright/test';
@@ -139,6 +141,7 @@ export const utils = {
         'user_token',
         'users',
         'system_metadata',
+        'system_config',
       ];
 
       const sql: string[] = [];
@@ -148,7 +151,12 @@ export const utils = {
       }
 
       for (const table of tables) {
-        sql.push(`DELETE FROM ${table} CASCADE;`);
+        if (table === 'system_metadata') {
+          // prevent reverse geocoder from being re-initialized
+          sql.push(`DELETE FROM "system_metadata" where "key" != 'reverse-geocoding-state';`);
+        } else {
+          sql.push(`DELETE FROM ${table} CASCADE;`);
+        }
       }
 
       await client.query(sql.join('\n'));
@@ -310,9 +318,7 @@ export const utils = {
     if (!existsSync(dirname(path))) {
       mkdirSync(dirname(path), { recursive: true });
     }
-    if (!existsSync(path)) {
-      writeFileSync(path, makeRandomImage());
-    }
+    writeFileSync(path, makeRandomImage());
   },
 
   removeImageFile: (path: string) => {
@@ -407,8 +413,14 @@ export const utils = {
       },
     ]),
 
-  deleteTempFolder: () => {
+  resetTempFolder: () => {
     rmSync(`${testAssetDir}/temp`, { recursive: true, force: true });
+    mkdirSync(`${testAssetDir}/temp`, { recursive: true });
+  },
+
+  resetAdminConfig: async (accessToken: string) => {
+    const defaultConfig = await getConfigDefaults({ headers: asBearerAuth(accessToken) });
+    await updateConfig({ systemConfigDto: defaultConfig }, { headers: asBearerAuth(accessToken) });
   },
 
   isQueueEmpty: async (accessToken: string, queue: keyof AllJobStatusResponseDto) => {
diff --git a/server/e2e/client/asset-api.ts b/server/e2e/client/asset-api.ts
deleted file mode 100644
index f32d586115..0000000000
--- a/server/e2e/client/asset-api.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import { AssetResponseDto } from 'src/dtos/asset-response.dto';
-import request from 'supertest';
-
-export const assetApi = {
-  getAllAssets: async (server: any, accessToken: string) => {
-    const { body, status } = await request(server).get(`/asset/`).set('Authorization', `Bearer ${accessToken}`);
-    expect(status).toBe(200);
-    return body as AssetResponseDto[];
-  },
-};
diff --git a/server/e2e/client/auth-api.ts b/server/e2e/client/auth-api.ts
deleted file mode 100644
index a8cfe4660a..0000000000
--- a/server/e2e/client/auth-api.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import { LoginResponseDto } from 'src/dtos/auth.dto';
-import { UserResponseDto } from 'src/dtos/user.dto';
-import request from 'supertest';
-import { adminSignupStub, loginResponseStub, loginStub } from 'test/fixtures/auth.stub';
-
-export const authApi = {
-  adminSignUp: async (server: any) => {
-    const { status, body } = await request(server).post('/auth/admin-sign-up').send(adminSignupStub);
-
-    expect(status).toBe(201);
-
-    return body as UserResponseDto;
-  },
-  adminLogin: async (server: any) => {
-    const { status, body } = await request(server).post('/auth/login').send(loginStub.admin);
-
-    expect(body).toEqual(loginResponseStub.admin.response);
-    expect(body).toMatchObject({ accessToken: expect.any(String) });
-    expect(status).toBe(201);
-
-    return body as LoginResponseDto;
-  },
-};
diff --git a/server/e2e/client/index.ts b/server/e2e/client/index.ts
deleted file mode 100644
index 41418ddcc0..0000000000
--- a/server/e2e/client/index.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import { assetApi } from 'e2e/client/asset-api';
-import { authApi } from 'e2e/client/auth-api';
-import { libraryApi } from 'e2e/client/library-api';
-
-export const api = {
-  authApi,
-  assetApi,
-  libraryApi,
-};
diff --git a/server/e2e/client/library-api.ts b/server/e2e/client/library-api.ts
deleted file mode 100644
index 70c8c4c360..0000000000
--- a/server/e2e/client/library-api.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-import { CreateLibraryDto, LibraryResponseDto, ScanLibraryDto } from 'src/dtos/library.dto';
-import request from 'supertest';
-
-export const libraryApi = {
-  getAll: async (server: any, accessToken: string) => {
-    const { body, status } = await request(server).get(`/library/`).set('Authorization', `Bearer ${accessToken}`);
-    expect(status).toBe(200);
-    return body as LibraryResponseDto[];
-  },
-  create: async (server: any, accessToken: string, dto: CreateLibraryDto) => {
-    const { body, status } = await request(server)
-      .post(`/library/`)
-      .set('Authorization', `Bearer ${accessToken}`)
-      .send(dto);
-    expect(status).toBe(201);
-    return body as LibraryResponseDto;
-  },
-  setImportPaths: async (server: any, accessToken: string, id: string, importPaths: string[]) => {
-    const { body, status } = await request(server)
-      .put(`/library/${id}`)
-      .set('Authorization', `Bearer ${accessToken}`)
-      .send({ importPaths });
-    expect(status).toBe(200);
-    return body as LibraryResponseDto;
-  },
-  scanLibrary: async (server: any, accessToken: string, id: string, dto: ScanLibraryDto = {}) => {
-    const { status } = await request(server)
-      .post(`/library/${id}/scan`)
-      .set('Authorization', `Bearer ${accessToken}`)
-      .send(dto);
-    expect(status).toBe(204);
-  },
-};
diff --git a/server/e2e/docker-compose.server-e2e.yml b/server/e2e/docker-compose.server-e2e.yml
deleted file mode 100644
index 61b38e1ee0..0000000000
--- a/server/e2e/docker-compose.server-e2e.yml
+++ /dev/null
@@ -1,33 +0,0 @@
-version: '3.8'
-
-name: 'immich-test-e2e'
-
-services:
-  immich-server:
-    image: immich-server-dev:latest
-    build:
-      context: ../../
-      dockerfile: server/Dockerfile
-      target: dev
-    command: ['/usr/src/app/bin/immich-test', 'jobs']
-    volumes:
-      - /usr/src/app/node_modules
-      - ../test/assets:/usr/src/app/test/assets:ro
-    environment:
-      - DB_HOSTNAME=database
-      - DB_USERNAME=postgres
-      - DB_PASSWORD=postgres
-      - DB_DATABASE_NAME=e2e_test
-      - IMMICH_METRICS=true
-    depends_on:
-      - database
-
-  database:
-    image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:90724186f0a3517cf6914295b5ab410db9ce23190a2d9d0b9dd6463e3fa298f0
-    command: -c fsync=off -c shared_preload_libraries=vectors.so
-    environment:
-      POSTGRES_PASSWORD: postgres
-      POSTGRES_USER: postgres
-      POSTGRES_DB: e2e_test
-    logging:
-      driver: none
diff --git a/server/e2e/jobs/config/library-watcher-e2e-config.json b/server/e2e/jobs/config/library-watcher-e2e-config.json
deleted file mode 100644
index 9f7420ca5a..0000000000
--- a/server/e2e/jobs/config/library-watcher-e2e-config.json
+++ /dev/null
@@ -1,17 +0,0 @@
-{
-  "reverseGeocoding": {
-    "enabled": false
-  },
-  "machineLearning": {
-    "enabled": false
-  },
-  "logging": {
-    "enabled": false,
-    "level": "debug"
-  },
-  "library": {
-    "watch": {
-      "enabled": true
-    }
-  }
-}
diff --git a/server/e2e/jobs/immich-e2e-config.json b/server/e2e/jobs/immich-e2e-config.json
deleted file mode 100644
index 4f018dc164..0000000000
--- a/server/e2e/jobs/immich-e2e-config.json
+++ /dev/null
@@ -1,12 +0,0 @@
-{
-  "reverseGeocoding": {
-    "enabled": false
-  },
-  "machineLearning": {
-    "enabled": false
-  },
-  "logging": {
-    "enabled": false,
-    "level": "debug"
-  }
-}
diff --git a/server/e2e/jobs/jest-e2e.json b/server/e2e/jobs/jest-e2e.json
deleted file mode 100644
index b7e62d5f46..0000000000
--- a/server/e2e/jobs/jest-e2e.json
+++ /dev/null
@@ -1,22 +0,0 @@
-{
-  "moduleFileExtensions": ["js", "json", "ts"],
-  "modulePaths": ["<rootDir>"],
-  "rootDir": "../..",
-  "globalSetup": "<rootDir>/e2e/jobs/setup.ts",
-  "testEnvironment": "node",
-  "testMatch": ["**/e2e/jobs/specs/*.e2e-spec.[tj]s"],
-  "testTimeout": 10000,
-  "transform": {
-    "^.+\\.(t|j)s$": "ts-jest"
-  },
-  "collectCoverageFrom": [
-    "<rootDir>/src/**/*.(t|j)s",
-    "!<rootDir>/src/**/*.spec.(t|s)s",
-    "!<rootDir>/src/migrations/**"
-  ],
-  "coverageDirectory": "./coverage",
-  "moduleNameMapper": {
-    "^test(|/.*)$": "<rootDir>/test/$1",
-    "^src(|/.*)$": "<rootDir>/src/$1"
-  }
-}
diff --git a/server/e2e/jobs/setup.ts b/server/e2e/jobs/setup.ts
deleted file mode 100644
index d1f566d372..0000000000
--- a/server/e2e/jobs/setup.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-import { PostgreSqlContainer } from '@testcontainers/postgresql';
-import { access } from 'fs/promises';
-import path from 'path';
-
-export default async () => {
-  let IMMICH_TEST_ASSET_PATH: string = '';
-
-  if (process.env.IMMICH_TEST_ASSET_PATH === undefined) {
-    IMMICH_TEST_ASSET_PATH = path.normalize(`${__dirname}/../../test/assets/`);
-    process.env.IMMICH_TEST_ASSET_PATH = IMMICH_TEST_ASSET_PATH;
-  } else {
-    IMMICH_TEST_ASSET_PATH = process.env.IMMICH_TEST_ASSET_PATH;
-  }
-
-  const directoryExists = async (dirPath: string) =>
-    await access(dirPath)
-      .then(() => true)
-      .catch(() => false);
-
-  if (!(await directoryExists(`${IMMICH_TEST_ASSET_PATH}/albums`))) {
-    throw new Error(
-      `Test assets not found. Please checkout https://github.com/immich-app/test-assets into ${IMMICH_TEST_ASSET_PATH} before testing`,
-    );
-  }
-
-  if (process.env.DB_HOSTNAME === undefined) {
-    // DB hostname not set which likely means we're not running e2e through docker compose. Start a local postgres container.
-    const pg = await new PostgreSqlContainer('tensorchord/pgvecto-rs:pg14-v0.2.0')
-      .withExposedPorts(5432)
-      .withDatabase('immich')
-      .withUsername('postgres')
-      .withPassword('postgres')
-      .withReuse()
-      .start();
-
-    process.env.DB_URL = pg.getConnectionUri();
-  }
-
-  process.env.NODE_ENV = 'development';
-  process.env.IMMICH_CONFIG_FILE = path.normalize(`${__dirname}/immich-e2e-config.json`);
-  process.env.TZ = 'Z';
-};
diff --git a/server/e2e/jobs/specs/library-watcher.e2e-spec.ts b/server/e2e/jobs/specs/library-watcher.e2e-spec.ts
deleted file mode 100644
index 20a9d32020..0000000000
--- a/server/e2e/jobs/specs/library-watcher.e2e-spec.ts
+++ /dev/null
@@ -1,231 +0,0 @@
-import { api } from 'e2e/client';
-import fs from 'node:fs/promises';
-import path from 'node:path';
-import { LoginResponseDto } from 'src/dtos/auth.dto';
-import { LibraryResponseDto } from 'src/dtos/library.dto';
-import { AssetType } from 'src/entities/asset.entity';
-import { LibraryType } from 'src/entities/library.entity';
-import { StorageEventType } from 'src/interfaces/storage.interface';
-import { LibraryService } from 'src/services/library.service';
-import {
-  IMMICH_TEST_ASSET_PATH,
-  IMMICH_TEST_ASSET_TEMP_PATH,
-  restoreTempFolder,
-  testApp,
-  waitForEvent,
-} from 'test/utils';
-
-describe(`Library watcher (e2e)`, () => {
-  let server: any;
-  let admin: LoginResponseDto;
-  let libraryService: LibraryService;
-  const configFilePath = process.env.IMMICH_CONFIG_FILE;
-
-  beforeAll(async () => {
-    process.env.IMMICH_CONFIG_FILE = path.normalize(`${__dirname}/../config/library-watcher-e2e-config.json`);
-
-    const app = await testApp.create();
-    server = app.getHttpServer();
-    libraryService = testApp.get(LibraryService);
-  });
-
-  beforeEach(async () => {
-    await testApp.reset();
-    await restoreTempFolder();
-    await api.authApi.adminSignUp(server);
-    admin = await api.authApi.adminLogin(server);
-  });
-
-  afterEach(async () => {
-    await libraryService.teardown();
-  });
-
-  afterAll(async () => {
-    await testApp.teardown();
-    await restoreTempFolder();
-    process.env.IMMICH_CONFIG_FILE = configFilePath;
-  });
-
-  describe('Event handling', () => {
-    describe('Single import path', () => {
-      beforeEach(async () => {
-        await api.libraryApi.create(server, admin.accessToken, {
-          ownerId: admin.userId,
-          type: LibraryType.EXTERNAL,
-          importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`],
-        });
-      });
-
-      it('should import a new file', async () => {
-        await fs.copyFile(
-          `${IMMICH_TEST_ASSET_PATH}/albums/nature/el_torcal_rocks.jpg`,
-          `${IMMICH_TEST_ASSET_TEMP_PATH}/file.jpg`,
-        );
-
-        await waitForEvent(libraryService, StorageEventType.ADD);
-
-        const afterAssets = await api.assetApi.getAllAssets(server, admin.accessToken);
-        expect(afterAssets.length).toEqual(1);
-      });
-
-      it('should import new files with case insensitive extensions', async () => {
-        await fs.copyFile(
-          `${IMMICH_TEST_ASSET_PATH}/albums/nature/el_torcal_rocks.jpg`,
-          `${IMMICH_TEST_ASSET_TEMP_PATH}/file2.JPG`,
-        );
-
-        await fs.copyFile(
-          `${IMMICH_TEST_ASSET_PATH}/albums/nature/el_torcal_rocks.jpg`,
-          `${IMMICH_TEST_ASSET_TEMP_PATH}/file3.Jpg`,
-        );
-
-        await fs.copyFile(
-          `${IMMICH_TEST_ASSET_PATH}/albums/nature/el_torcal_rocks.jpg`,
-          `${IMMICH_TEST_ASSET_TEMP_PATH}/file4.jpG`,
-        );
-
-        await fs.copyFile(
-          `${IMMICH_TEST_ASSET_PATH}/albums/nature/el_torcal_rocks.jpg`,
-          `${IMMICH_TEST_ASSET_TEMP_PATH}/file5.jPg`,
-        );
-
-        await waitForEvent(libraryService, StorageEventType.ADD, 4);
-
-        const afterAssets = await api.assetApi.getAllAssets(server, admin.accessToken);
-        expect(afterAssets.length).toEqual(4);
-      });
-
-      it('should update a changed file', async () => {
-        await fs.copyFile(
-          `${IMMICH_TEST_ASSET_PATH}/albums/nature/el_torcal_rocks.jpg`,
-          `${IMMICH_TEST_ASSET_TEMP_PATH}/file.jpg`,
-        );
-
-        await waitForEvent(libraryService, StorageEventType.ADD);
-
-        const originalAssets = await api.assetApi.getAllAssets(server, admin.accessToken);
-        expect(originalAssets.length).toEqual(1);
-
-        await fs.copyFile(
-          `${IMMICH_TEST_ASSET_PATH}/albums/nature/prairie_falcon.jpg`,
-          `${IMMICH_TEST_ASSET_TEMP_PATH}/file.jpg`,
-        );
-
-        await waitForEvent(libraryService, StorageEventType.CHANGE);
-
-        const afterAssets = await api.assetApi.getAllAssets(server, admin.accessToken);
-        expect(afterAssets).toEqual([
-          expect.objectContaining({
-            // Make sure we keep the original asset id
-            id: originalAssets[0].id,
-            type: AssetType.IMAGE,
-            exifInfo: expect.objectContaining({
-              make: 'Canon',
-              model: 'Canon EOS R5',
-              exifImageWidth: 800,
-              exifImageHeight: 533,
-              exposureTime: '1/4000',
-            }),
-          }),
-        ]);
-      });
-    });
-
-    describe('Multiple import paths', () => {
-      beforeEach(async () => {
-        await fs.mkdir(`${IMMICH_TEST_ASSET_TEMP_PATH}/dir1`, { recursive: true });
-        await fs.mkdir(`${IMMICH_TEST_ASSET_TEMP_PATH}/dir2`, { recursive: true });
-        await fs.mkdir(`${IMMICH_TEST_ASSET_TEMP_PATH}/dir3`, { recursive: true });
-
-        await api.libraryApi.create(server, admin.accessToken, {
-          ownerId: admin.userId,
-          type: LibraryType.EXTERNAL,
-          importPaths: [
-            `${IMMICH_TEST_ASSET_TEMP_PATH}/dir1`,
-            `${IMMICH_TEST_ASSET_TEMP_PATH}/dir2`,
-            `${IMMICH_TEST_ASSET_TEMP_PATH}/dir3`,
-          ],
-        });
-      });
-
-      it('should add new files in multiple import paths', async () => {
-        await fs.copyFile(
-          `${IMMICH_TEST_ASSET_PATH}/albums/nature/el_torcal_rocks.jpg`,
-          `${IMMICH_TEST_ASSET_TEMP_PATH}/dir1/file2.jpg`,
-        );
-
-        await fs.copyFile(
-          `${IMMICH_TEST_ASSET_PATH}/albums/nature/polemonium_reptans.jpg`,
-          `${IMMICH_TEST_ASSET_TEMP_PATH}/dir2/file3.jpg`,
-        );
-
-        await fs.copyFile(
-          `${IMMICH_TEST_ASSET_PATH}/albums/nature/tanners_ridge.jpg`,
-          `${IMMICH_TEST_ASSET_TEMP_PATH}/dir3/file4.jpg`,
-        );
-
-        await waitForEvent(libraryService, StorageEventType.ADD, 3);
-
-        const assets = await api.assetApi.getAllAssets(server, admin.accessToken);
-        expect(assets.length).toEqual(3);
-      });
-
-      it('should offline a removed file', async () => {
-        await fs.copyFile(
-          `${IMMICH_TEST_ASSET_PATH}/albums/nature/polemonium_reptans.jpg`,
-          `${IMMICH_TEST_ASSET_TEMP_PATH}/dir1/file.jpg`,
-        );
-
-        await waitForEvent(libraryService, StorageEventType.ADD);
-
-        const addedAssets = await api.assetApi.getAllAssets(server, admin.accessToken);
-        expect(addedAssets.length).toEqual(1);
-
-        await fs.unlink(`${IMMICH_TEST_ASSET_TEMP_PATH}/dir1/file.jpg`);
-
-        await waitForEvent(libraryService, StorageEventType.UNLINK);
-
-        const afterAssets = await api.assetApi.getAllAssets(server, admin.accessToken);
-        expect(afterAssets[0].isOffline).toEqual(true);
-      });
-    });
-  });
-
-  describe('Configuration', () => {
-    let library: LibraryResponseDto;
-
-    beforeEach(async () => {
-      library = await api.libraryApi.create(server, admin.accessToken, {
-        ownerId: admin.userId,
-        type: LibraryType.EXTERNAL,
-        importPaths: [
-          `${IMMICH_TEST_ASSET_TEMP_PATH}/dir1`,
-          `${IMMICH_TEST_ASSET_TEMP_PATH}/dir2`,
-          `${IMMICH_TEST_ASSET_TEMP_PATH}/dir3`,
-        ],
-      });
-
-      await fs.mkdir(`${IMMICH_TEST_ASSET_TEMP_PATH}/dir1`, { recursive: true });
-      await fs.mkdir(`${IMMICH_TEST_ASSET_TEMP_PATH}/dir2`, { recursive: true });
-      await fs.mkdir(`${IMMICH_TEST_ASSET_TEMP_PATH}/dir3`, { recursive: true });
-    });
-
-    it('should use an updated import path', async () => {
-      await fs.mkdir(`${IMMICH_TEST_ASSET_TEMP_PATH}/dir4`, { recursive: true });
-
-      await api.libraryApi.setImportPaths(server, admin.accessToken, library.id, [
-        `${IMMICH_TEST_ASSET_TEMP_PATH}/dir4`,
-      ]);
-
-      await fs.copyFile(
-        `${IMMICH_TEST_ASSET_PATH}/albums/nature/polemonium_reptans.jpg`,
-        `${IMMICH_TEST_ASSET_TEMP_PATH}/dir4/file.jpg`,
-      );
-
-      await waitForEvent(libraryService, StorageEventType.ADD);
-
-      const afterAssets = await api.assetApi.getAllAssets(server, admin.accessToken);
-      expect(afterAssets.length).toEqual(1);
-    });
-  });
-});
diff --git a/server/package-lock.json b/server/package-lock.json
index e473a94d0b..a0dc11ad22 100644
--- a/server/package-lock.json
+++ b/server/package-lock.json
@@ -81,7 +81,6 @@
         "@types/mock-fs": "^4.13.1",
         "@types/multer": "^1.4.7",
         "@types/node": "^20.5.7",
-        "@types/supertest": "^6.0.0",
         "@types/ua-parser-js": "^0.7.36",
         "@typescript-eslint/eslint-plugin": "^7.0.0",
         "@typescript-eslint/parser": "^7.0.0",
@@ -98,8 +97,6 @@
         "rimraf": "^5.0.1",
         "source-map-support": "^0.5.21",
         "sql-formatter": "^15.0.0",
-        "supertest": "^6.3.3",
-        "testcontainers": "^10.2.1",
         "ts-jest": "^29.1.1",
         "ts-loader": "^9.4.4",
         "ts-node": "^10.9.1",
@@ -4390,12 +4387,6 @@
         "@types/express": "*"
       }
     },
-    "node_modules/@types/cookiejar": {
-      "version": "2.1.5",
-      "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz",
-      "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==",
-      "dev": true
-    },
     "node_modules/@types/cookies": {
       "version": "0.9.0",
       "resolved": "https://registry.npmjs.org/@types/cookies/-/cookies-0.9.0.tgz",
@@ -4685,12 +4676,6 @@
         "@types/node": "*"
       }
     },
-    "node_modules/@types/methods": {
-      "version": "1.1.4",
-      "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
-      "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==",
-      "dev": true
-    },
     "node_modules/@types/mime": {
       "version": "1.3.3",
       "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.3.tgz",
@@ -4890,27 +4875,6 @@
       "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==",
       "dev": true
     },
-    "node_modules/@types/superagent": {
-      "version": "8.1.1",
-      "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.1.tgz",
-      "integrity": "sha512-YQyEXA4PgCl7EVOoSAS3o0fyPFU6erv5mMixztQYe1bqbWmmn8c+IrqoxjQeZe4MgwXikgcaZPiI/DsbmOVlzA==",
-      "dev": true,
-      "dependencies": {
-        "@types/cookiejar": "^2.1.5",
-        "@types/methods": "^1.1.4",
-        "@types/node": "*"
-      }
-    },
-    "node_modules/@types/supertest": {
-      "version": "6.0.2",
-      "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-6.0.2.tgz",
-      "integrity": "sha512-137ypx2lk/wTQbW6An6safu9hXmajAifU/s7szAHLN/FeIm5w7yR0Wkl9fdJMRSHwOn4HLAI0DaB2TOORuhPDg==",
-      "dev": true,
-      "dependencies": {
-        "@types/methods": "^1.1.4",
-        "@types/superagent": "^8.1.0"
-      }
-    },
     "node_modules/@types/tedious": {
       "version": "4.0.14",
       "resolved": "https://registry.npmjs.org/@types/tedious/-/tedious-4.0.14.tgz",
@@ -5709,12 +5673,6 @@
         "node": ">=8"
       }
     },
-    "node_modules/asap": {
-      "version": "2.0.6",
-      "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz",
-      "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==",
-      "dev": true
-    },
     "node_modules/asn1": {
       "version": "0.2.6",
       "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
@@ -5734,12 +5692,6 @@
       "resolved": "https://registry.npmjs.org/async-lock/-/async-lock-1.4.1.tgz",
       "integrity": "sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ=="
     },
-    "node_modules/asynckit": {
-      "version": "0.4.0",
-      "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
-      "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
-      "dev": true
-    },
     "node_modules/b4a": {
       "version": "1.6.4",
       "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.4.tgz",
@@ -6587,18 +6539,6 @@
         "color-support": "bin.js"
       }
     },
-    "node_modules/combined-stream": {
-      "version": "1.0.8",
-      "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
-      "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
-      "dev": true,
-      "dependencies": {
-        "delayed-stream": "~1.0.0"
-      },
-      "engines": {
-        "node": ">= 0.8"
-      }
-    },
     "node_modules/commander": {
       "version": "4.1.1",
       "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
@@ -6624,12 +6564,6 @@
         "node": ">= 6"
       }
     },
-    "node_modules/component-emitter": {
-      "version": "1.3.0",
-      "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz",
-      "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==",
-      "dev": true
-    },
     "node_modules/compress-commons": {
       "version": "6.0.2",
       "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz",
@@ -6762,12 +6696,6 @@
       "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
       "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ=="
     },
-    "node_modules/cookiejar": {
-      "version": "2.1.4",
-      "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz",
-      "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==",
-      "dev": true
-    },
     "node_modules/core-js-compat": {
       "version": "3.35.1",
       "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.35.1.tgz",
@@ -7037,15 +6965,6 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
-    "node_modules/delayed-stream": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
-      "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
-      "dev": true,
-      "engines": {
-        "node": ">=0.4.0"
-      }
-    },
     "node_modules/delegates": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
@@ -7093,16 +7012,6 @@
         "node": ">=8"
       }
     },
-    "node_modules/dezalgo": {
-      "version": "1.0.4",
-      "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz",
-      "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==",
-      "dev": true,
-      "dependencies": {
-        "asap": "^2.0.0",
-        "wrappy": "1"
-      }
-    },
     "node_modules/diacritics": {
       "version": "1.3.0",
       "resolved": "https://registry.npmjs.org/diacritics/-/diacritics-1.3.0.tgz",
@@ -8172,35 +8081,6 @@
         "webpack": "^5.11.0"
       }
     },
-    "node_modules/form-data": {
-      "version": "4.0.0",
-      "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
-      "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
-      "dev": true,
-      "dependencies": {
-        "asynckit": "^0.4.0",
-        "combined-stream": "^1.0.8",
-        "mime-types": "^2.1.12"
-      },
-      "engines": {
-        "node": ">= 6"
-      }
-    },
-    "node_modules/formidable": {
-      "version": "2.1.2",
-      "resolved": "https://registry.npmjs.org/formidable/-/formidable-2.1.2.tgz",
-      "integrity": "sha512-CM3GuJ57US06mlpQ47YcunuUZ9jpm8Vx+P2CGt2j7HpgkKZO/DJYQ0Bobim8G6PFQmK5lOqOOdUXboU+h73A4g==",
-      "dev": true,
-      "dependencies": {
-        "dezalgo": "^1.0.4",
-        "hexoid": "^1.0.0",
-        "once": "^1.4.0",
-        "qs": "^6.11.0"
-      },
-      "funding": {
-        "url": "https://ko-fi.com/tunnckoCore/commissions"
-      }
-    },
     "node_modules/forwarded": {
       "version": "0.2.0",
       "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -8711,15 +8591,6 @@
         "he": "bin/he"
       }
     },
-    "node_modules/hexoid": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/hexoid/-/hexoid-1.0.0.tgz",
-      "integrity": "sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g==",
-      "dev": true,
-      "engines": {
-        "node": ">=8"
-      }
-    },
     "node_modules/highlight.js": {
       "version": "10.7.3",
       "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz",
@@ -12922,52 +12793,6 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
-    "node_modules/superagent": {
-      "version": "8.1.2",
-      "resolved": "https://registry.npmjs.org/superagent/-/superagent-8.1.2.tgz",
-      "integrity": "sha512-6WTxW1EB6yCxV5VFOIPQruWGHqc3yI7hEmZK6h+pyk69Lk/Ut7rLUY6W/ONF2MjBuGjvmMiIpsrVJ2vjrHlslA==",
-      "dev": true,
-      "dependencies": {
-        "component-emitter": "^1.3.0",
-        "cookiejar": "^2.1.4",
-        "debug": "^4.3.4",
-        "fast-safe-stringify": "^2.1.1",
-        "form-data": "^4.0.0",
-        "formidable": "^2.1.2",
-        "methods": "^1.1.2",
-        "mime": "2.6.0",
-        "qs": "^6.11.0",
-        "semver": "^7.3.8"
-      },
-      "engines": {
-        "node": ">=6.4.0 <13 || >=14"
-      }
-    },
-    "node_modules/superagent/node_modules/mime": {
-      "version": "2.6.0",
-      "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz",
-      "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==",
-      "dev": true,
-      "bin": {
-        "mime": "cli.js"
-      },
-      "engines": {
-        "node": ">=4.0.0"
-      }
-    },
-    "node_modules/supertest": {
-      "version": "6.3.4",
-      "resolved": "https://registry.npmjs.org/supertest/-/supertest-6.3.4.tgz",
-      "integrity": "sha512-erY3HFDG0dPnhw4U+udPfrzXa4xhSG+n4rxfRuZWCUvjFWwKl+OxWf/7zk50s84/fAAs7vf5QAb9uRa0cCykxw==",
-      "dev": true,
-      "dependencies": {
-        "methods": "^1.1.2",
-        "superagent": "^8.1.2"
-      },
-      "engines": {
-        "node": ">=6.4.0"
-      }
-    },
     "node_modules/supports-color": {
       "version": "7.2.0",
       "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -17435,12 +17260,6 @@
         "@types/express": "*"
       }
     },
-    "@types/cookiejar": {
-      "version": "2.1.5",
-      "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz",
-      "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==",
-      "dev": true
-    },
     "@types/cookies": {
       "version": "0.9.0",
       "resolved": "https://registry.npmjs.org/@types/cookies/-/cookies-0.9.0.tgz",
@@ -17729,12 +17548,6 @@
         "@types/node": "*"
       }
     },
-    "@types/methods": {
-      "version": "1.1.4",
-      "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
-      "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==",
-      "dev": true
-    },
     "@types/mime": {
       "version": "1.3.3",
       "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.3.tgz",
@@ -17921,27 +17734,6 @@
       "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==",
       "dev": true
     },
-    "@types/superagent": {
-      "version": "8.1.1",
-      "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.1.tgz",
-      "integrity": "sha512-YQyEXA4PgCl7EVOoSAS3o0fyPFU6erv5mMixztQYe1bqbWmmn8c+IrqoxjQeZe4MgwXikgcaZPiI/DsbmOVlzA==",
-      "dev": true,
-      "requires": {
-        "@types/cookiejar": "^2.1.5",
-        "@types/methods": "^1.1.4",
-        "@types/node": "*"
-      }
-    },
-    "@types/supertest": {
-      "version": "6.0.2",
-      "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-6.0.2.tgz",
-      "integrity": "sha512-137ypx2lk/wTQbW6An6safu9hXmajAifU/s7szAHLN/FeIm5w7yR0Wkl9fdJMRSHwOn4HLAI0DaB2TOORuhPDg==",
-      "dev": true,
-      "requires": {
-        "@types/methods": "^1.1.4",
-        "@types/superagent": "^8.1.0"
-      }
-    },
     "@types/tedious": {
       "version": "4.0.14",
       "resolved": "https://registry.npmjs.org/@types/tedious/-/tedious-4.0.14.tgz",
@@ -18536,12 +18328,6 @@
       "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==",
       "dev": true
     },
-    "asap": {
-      "version": "2.0.6",
-      "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz",
-      "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==",
-      "dev": true
-    },
     "asn1": {
       "version": "0.2.6",
       "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
@@ -18561,12 +18347,6 @@
       "resolved": "https://registry.npmjs.org/async-lock/-/async-lock-1.4.1.tgz",
       "integrity": "sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ=="
     },
-    "asynckit": {
-      "version": "0.4.0",
-      "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
-      "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
-      "dev": true
-    },
     "b4a": {
       "version": "1.6.4",
       "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.4.tgz",
@@ -19182,15 +18962,6 @@
       "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
       "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg=="
     },
-    "combined-stream": {
-      "version": "1.0.8",
-      "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
-      "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
-      "dev": true,
-      "requires": {
-        "delayed-stream": "~1.0.0"
-      }
-    },
     "commander": {
       "version": "4.1.1",
       "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
@@ -19210,12 +18981,6 @@
         "repeat-string": "^1.6.1"
       }
     },
-    "component-emitter": {
-      "version": "1.3.0",
-      "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz",
-      "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==",
-      "dev": true
-    },
     "compress-commons": {
       "version": "6.0.2",
       "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz",
@@ -19315,12 +19080,6 @@
       "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
       "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ=="
     },
-    "cookiejar": {
-      "version": "2.1.4",
-      "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz",
-      "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==",
-      "dev": true
-    },
     "core-js-compat": {
       "version": "3.35.1",
       "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.35.1.tgz",
@@ -19502,12 +19261,6 @@
         "has-property-descriptors": "^1.0.1"
       }
     },
-    "delayed-stream": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
-      "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
-      "dev": true
-    },
     "delegates": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
@@ -19539,16 +19292,6 @@
       "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==",
       "dev": true
     },
-    "dezalgo": {
-      "version": "1.0.4",
-      "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz",
-      "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==",
-      "dev": true,
-      "requires": {
-        "asap": "^2.0.0",
-        "wrappy": "1"
-      }
-    },
     "diacritics": {
       "version": "1.3.0",
       "resolved": "https://registry.npmjs.org/diacritics/-/diacritics-1.3.0.tgz",
@@ -20373,29 +20116,6 @@
         "tapable": "^2.2.1"
       }
     },
-    "form-data": {
-      "version": "4.0.0",
-      "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
-      "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
-      "dev": true,
-      "requires": {
-        "asynckit": "^0.4.0",
-        "combined-stream": "^1.0.8",
-        "mime-types": "^2.1.12"
-      }
-    },
-    "formidable": {
-      "version": "2.1.2",
-      "resolved": "https://registry.npmjs.org/formidable/-/formidable-2.1.2.tgz",
-      "integrity": "sha512-CM3GuJ57US06mlpQ47YcunuUZ9jpm8Vx+P2CGt2j7HpgkKZO/DJYQ0Bobim8G6PFQmK5lOqOOdUXboU+h73A4g==",
-      "dev": true,
-      "requires": {
-        "dezalgo": "^1.0.4",
-        "hexoid": "^1.0.0",
-        "once": "^1.4.0",
-        "qs": "^6.11.0"
-      }
-    },
     "forwarded": {
       "version": "0.2.0",
       "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -20758,12 +20478,6 @@
       "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
       "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="
     },
-    "hexoid": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/hexoid/-/hexoid-1.0.0.tgz",
-      "integrity": "sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g==",
-      "dev": true
-    },
     "highlight.js": {
       "version": "10.7.3",
       "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz",
@@ -23956,42 +23670,6 @@
       "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
       "dev": true
     },
-    "superagent": {
-      "version": "8.1.2",
-      "resolved": "https://registry.npmjs.org/superagent/-/superagent-8.1.2.tgz",
-      "integrity": "sha512-6WTxW1EB6yCxV5VFOIPQruWGHqc3yI7hEmZK6h+pyk69Lk/Ut7rLUY6W/ONF2MjBuGjvmMiIpsrVJ2vjrHlslA==",
-      "dev": true,
-      "requires": {
-        "component-emitter": "^1.3.0",
-        "cookiejar": "^2.1.4",
-        "debug": "^4.3.4",
-        "fast-safe-stringify": "^2.1.1",
-        "form-data": "^4.0.0",
-        "formidable": "^2.1.2",
-        "methods": "^1.1.2",
-        "mime": "2.6.0",
-        "qs": "^6.11.0",
-        "semver": "^7.3.8"
-      },
-      "dependencies": {
-        "mime": {
-          "version": "2.6.0",
-          "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz",
-          "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==",
-          "dev": true
-        }
-      }
-    },
-    "supertest": {
-      "version": "6.3.4",
-      "resolved": "https://registry.npmjs.org/supertest/-/supertest-6.3.4.tgz",
-      "integrity": "sha512-erY3HFDG0dPnhw4U+udPfrzXa4xhSG+n4rxfRuZWCUvjFWwKl+OxWf/7zk50s84/fAAs7vf5QAb9uRa0cCykxw==",
-      "dev": true,
-      "requires": {
-        "methods": "^1.1.2",
-        "superagent": "^8.1.2"
-      }
-    },
     "supports-color": {
       "version": "7.2.0",
       "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
diff --git a/server/package.json b/server/package.json
index 94b21d922f..5e2a453d0b 100644
--- a/server/package.json
+++ b/server/package.json
@@ -105,7 +105,6 @@
     "@types/mock-fs": "^4.13.1",
     "@types/multer": "^1.4.7",
     "@types/node": "^20.5.7",
-    "@types/supertest": "^6.0.0",
     "@types/ua-parser-js": "^0.7.36",
     "@typescript-eslint/eslint-plugin": "^7.0.0",
     "@typescript-eslint/parser": "^7.0.0",
@@ -122,8 +121,6 @@
     "rimraf": "^5.0.1",
     "source-map-support": "^0.5.21",
     "sql-formatter": "^15.0.0",
-    "supertest": "^6.3.3",
-    "testcontainers": "^10.2.1",
     "ts-jest": "^29.1.1",
     "ts-loader": "^9.4.4",
     "ts-node": "^10.9.1",
diff --git a/server/src/interfaces/storage.interface.ts b/server/src/interfaces/storage.interface.ts
index e78bb0195d..1bd49a3f20 100644
--- a/server/src/interfaces/storage.interface.ts
+++ b/server/src/interfaces/storage.interface.ts
@@ -31,14 +31,6 @@ export interface WatchEvents {
   onError(error: Error): void;
 }
 
-export enum StorageEventType {
-  READY = 'ready',
-  ADD = 'add',
-  CHANGE = 'change',
-  UNLINK = 'unlink',
-  ERROR = 'error',
-}
-
 export interface IStorageRepository {
   createZipStream(): ImmichZipStream;
   createReadStream(filepath: string, mimeType?: string | null): Promise<ImmichReadStream>;
diff --git a/server/src/repositories/storage.repository.ts b/server/src/repositories/storage.repository.ts
index 7e1c8d59e2..8f0dfd3ff9 100644
--- a/server/src/repositories/storage.repository.ts
+++ b/server/src/repositories/storage.repository.ts
@@ -10,7 +10,6 @@ import {
   IStorageRepository,
   ImmichReadStream,
   ImmichZipStream,
-  StorageEventType,
   WatchEvents,
 } from 'src/interfaces/storage.interface';
 import { Instrumentation } from 'src/utils/instrumentation';
@@ -173,11 +172,11 @@ export class StorageRepository implements IStorageRepository {
   watch(paths: string[], options: WatchOptions, events: Partial<WatchEvents>) {
     const watcher = chokidar.watch(paths, options);
 
-    watcher.on(StorageEventType.READY, () => events.onReady?.());
-    watcher.on(StorageEventType.ADD, (path) => events.onAdd?.(path));
-    watcher.on(StorageEventType.CHANGE, (path) => events.onChange?.(path));
-    watcher.on(StorageEventType.UNLINK, (path) => events.onUnlink?.(path));
-    watcher.on(StorageEventType.ERROR, (error) => events.onError?.(error));
+    watcher.on('ready', () => events.onReady?.());
+    watcher.on('add', (path) => events.onAdd?.(path));
+    watcher.on('change', (path) => events.onChange?.(path));
+    watcher.on('unlink', (path) => events.onUnlink?.(path));
+    watcher.on('error', (error) => events.onError?.(error));
 
     return () => watcher.close();
   }
diff --git a/server/src/services/library.service.spec.ts b/server/src/services/library.service.spec.ts
index 5f3dc45de1..0a6026b252 100644
--- a/server/src/services/library.service.spec.ts
+++ b/server/src/services/library.service.spec.ts
@@ -13,7 +13,7 @@ import { ICryptoRepository } from 'src/interfaces/crypto.interface';
 import { IDatabaseRepository } from 'src/interfaces/database.interface';
 import { IJobRepository, ILibraryFileJob, ILibraryRefreshJob, JobName, JobStatus } from 'src/interfaces/job.interface';
 import { ILibraryRepository } from 'src/interfaces/library.interface';
-import { IStorageRepository, StorageEventType } from 'src/interfaces/storage.interface';
+import { IStorageRepository } from 'src/interfaces/storage.interface';
 import { ISystemConfigRepository } from 'src/interfaces/system-config.interface';
 import { LibraryService } from 'src/services/library.service';
 import { assetStub } from 'test/fixtures/asset.stub';
@@ -933,12 +933,6 @@ describe(LibraryService.name, () => {
           type: LibraryType.EXTERNAL,
           importPaths: libraryStub.externalLibraryWithImportPaths1.importPaths,
         });
-
-        expect(storageMock.watch).toHaveBeenCalledWith(
-          libraryStub.externalLibraryWithImportPaths1.importPaths,
-          expect.anything(),
-          expect.anything(),
-        );
       });
 
       it('should create with exclusion patterns', async () => {
@@ -1087,45 +1081,6 @@ describe(LibraryService.name, () => {
       await expect(sut.update('library-id', {})).resolves.toEqual(mapLibrary(libraryStub.uploadLibrary1));
       expect(libraryMock.update).toHaveBeenCalledWith(expect.objectContaining({ id: 'library-id' }));
     });
-
-    it('should re-watch library when updating import paths', async () => {
-      libraryMock.update.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1);
-      libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1);
-
-      storageMock.stat.mockResolvedValue({
-        isDirectory: () => true,
-      } as Stats);
-
-      storageMock.checkFileExists.mockResolvedValue(true);
-
-      await expect(sut.update('library-id', { importPaths: ['/data/user1/foo'] })).resolves.toEqual(
-        mapLibrary(libraryStub.externalLibraryWithImportPaths1),
-      );
-
-      expect(libraryMock.update).toHaveBeenCalledWith(expect.objectContaining({ id: 'library-id' }));
-      expect(storageMock.watch).toHaveBeenCalledWith(
-        libraryStub.externalLibraryWithImportPaths1.importPaths,
-        expect.anything(),
-        expect.anything(),
-      );
-    });
-
-    it('should re-watch library when updating exclusion patterns', async () => {
-      libraryMock.update.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1);
-      configMock.load.mockResolvedValue(systemConfigStub.libraryWatchEnabled);
-      libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1);
-
-      await expect(sut.update('library-id', { exclusionPatterns: ['bar'] })).resolves.toEqual(
-        mapLibrary(libraryStub.externalLibraryWithImportPaths1),
-      );
-
-      expect(libraryMock.update).toHaveBeenCalledWith(expect.objectContaining({ id: 'library-id' }));
-      expect(storageMock.watch).toHaveBeenCalledWith(
-        expect.arrayContaining([expect.any(String)]),
-        expect.anything(),
-        expect.anything(),
-      );
-    });
   });
 
   describe('watchAll', () => {
@@ -1198,9 +1153,7 @@ describe(LibraryService.name, () => {
       it('should handle a new file event', async () => {
         libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1);
         libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]);
-        storageMock.watch.mockImplementation(
-          makeMockWatcher({ items: [{ event: StorageEventType.ADD, value: '/foo/photo.jpg' }] }),
-        );
+        storageMock.watch.mockImplementation(makeMockWatcher({ items: [{ event: 'add', value: '/foo/photo.jpg' }] }));
 
         await sut.watchAll();
 
@@ -1221,7 +1174,7 @@ describe(LibraryService.name, () => {
         libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1);
         libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]);
         storageMock.watch.mockImplementation(
-          makeMockWatcher({ items: [{ event: StorageEventType.CHANGE, value: '/foo/photo.jpg' }] }),
+          makeMockWatcher({ items: [{ event: 'change', value: '/foo/photo.jpg' }] }),
         );
 
         await sut.watchAll();
@@ -1244,7 +1197,7 @@ describe(LibraryService.name, () => {
         libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]);
         assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.external);
         storageMock.watch.mockImplementation(
-          makeMockWatcher({ items: [{ event: StorageEventType.UNLINK, value: '/foo/photo.jpg' }] }),
+          makeMockWatcher({ items: [{ event: 'unlink', value: '/foo/photo.jpg' }] }),
         );
 
         await sut.watchAll();
@@ -1258,19 +1211,17 @@ describe(LibraryService.name, () => {
         libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]);
         storageMock.watch.mockImplementation(
           makeMockWatcher({
-            items: [{ event: StorageEventType.ERROR, value: 'Error!' }],
+            items: [{ event: 'error', value: 'Error!' }],
           }),
         );
 
-        await expect(sut.watchAll()).rejects.toThrow('Error!');
+        await expect(sut.watchAll()).resolves.toBeUndefined();
       });
 
       it('should ignore unknown extensions', async () => {
         libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1);
         libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]);
-        storageMock.watch.mockImplementation(
-          makeMockWatcher({ items: [{ event: StorageEventType.ADD, value: '/foo/photo.jpg' }] }),
-        );
+        storageMock.watch.mockImplementation(makeMockWatcher({ items: [{ event: 'add', value: '/foo/photo.jpg' }] }));
 
         await sut.watchAll();
 
@@ -1280,9 +1231,7 @@ describe(LibraryService.name, () => {
       it('should ignore excluded paths', async () => {
         libraryMock.get.mockResolvedValue(libraryStub.patternPath);
         libraryMock.getAll.mockResolvedValue([libraryStub.patternPath]);
-        storageMock.watch.mockImplementation(
-          makeMockWatcher({ items: [{ event: StorageEventType.ADD, value: '/dir1/photo.txt' }] }),
-        );
+        storageMock.watch.mockImplementation(makeMockWatcher({ items: [{ event: 'add', value: '/dir1/photo.txt' }] }));
 
         await sut.watchAll();
 
@@ -1292,9 +1241,7 @@ describe(LibraryService.name, () => {
       it('should ignore excluded paths without case sensitivity', async () => {
         libraryMock.get.mockResolvedValue(libraryStub.patternPath);
         libraryMock.getAll.mockResolvedValue([libraryStub.patternPath]);
-        storageMock.watch.mockImplementation(
-          makeMockWatcher({ items: [{ event: StorageEventType.ADD, value: '/DIR1/photo.txt' }] }),
-        );
+        storageMock.watch.mockImplementation(makeMockWatcher({ items: [{ event: 'add', value: '/DIR1/photo.txt' }] }));
 
         await sut.watchAll();
 
diff --git a/server/src/services/library.service.ts b/server/src/services/library.service.ts
index 4dd47f9ad5..848c40f78e 100644
--- a/server/src/services/library.service.ts
+++ b/server/src/services/library.service.ts
@@ -1,7 +1,6 @@
 import { BadRequestException, Inject, Injectable } from '@nestjs/common';
 import { Trie } from 'mnemonist';
 import { R_OK } from 'node:constants';
-import { EventEmitter } from 'node:events';
 import { Stats } from 'node:fs';
 import path, { basename, parse } from 'node:path';
 import picomatch from 'picomatch';
@@ -37,7 +36,7 @@ import {
   JobStatus,
 } from 'src/interfaces/job.interface';
 import { ILibraryRepository } from 'src/interfaces/library.interface';
-import { IStorageRepository, StorageEventType } from 'src/interfaces/storage.interface';
+import { IStorageRepository } from 'src/interfaces/storage.interface';
 import { ISystemConfigRepository } from 'src/interfaces/system-config.interface';
 import { ImmichLogger } from 'src/utils/logger';
 import { mimeTypes } from 'src/utils/mime-types';
@@ -48,7 +47,7 @@ import { validateCronExpression } from 'src/validation';
 const LIBRARY_SCAN_BATCH_SIZE = 5000;
 
 @Injectable()
-export class LibraryService extends EventEmitter {
+export class LibraryService {
   readonly logger = new ImmichLogger(LibraryService.name);
   private configCore: SystemConfigCore;
   private watchLibraries = false;
@@ -64,7 +63,6 @@ export class LibraryService extends EventEmitter {
     @Inject(IStorageRepository) private storageRepository: IStorageRepository,
     @Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository,
   ) {
-    super();
     this.configCore = SystemConfigCore.create(configRepository);
   }
 
@@ -152,7 +150,6 @@ export class LibraryService extends EventEmitter {
             if (matcher(path)) {
               await this.scanAssets(library.id, [path], library.ownerId, false);
             }
-            this.emit(StorageEventType.ADD, path);
           };
           return handlePromiseError(handler(), this.logger);
         },
@@ -163,7 +160,6 @@ export class LibraryService extends EventEmitter {
               // Note: if the changed file was not previously imported, it will be imported now.
               await this.scanAssets(library.id, [path], library.ownerId, false);
             }
-            this.emit(StorageEventType.CHANGE, path);
           };
           return handlePromiseError(handler(), this.logger);
         },
@@ -174,13 +170,11 @@ export class LibraryService extends EventEmitter {
             if (asset && matcher(path)) {
               await this.assetRepository.update({ id: asset.id, isOffline: true });
             }
-            this.emit(StorageEventType.UNLINK, path);
           };
           return handlePromiseError(handler(), this.logger);
         },
         onError: (error) => {
           this.logger.error(`Library watcher for library ${library.id} encountered error: ${error}`);
-          this.emit(StorageEventType.ERROR, error);
         },
       },
     );
@@ -281,10 +275,6 @@ export class LibraryService extends EventEmitter {
 
     this.logger.log(`Creating ${dto.type} library for ${dto.ownerId}}`);
 
-    if (dto.type === LibraryType.EXTERNAL) {
-      await this.watch(library.id);
-    }
-
     return mapLibrary(library);
   }
 
@@ -368,11 +358,6 @@ export class LibraryService extends EventEmitter {
       }
     }
 
-    if (dto.importPaths || dto.exclusionPatterns) {
-      // Re-watch library to use new paths and/or exclusion patterns
-      await this.watch(id);
-    }
-
     return mapLibrary(library);
   }
 
diff --git a/server/test/repositories/storage.repository.mock.ts b/server/test/repositories/storage.repository.mock.ts
index d5049999c7..e88657684b 100644
--- a/server/test/repositories/storage.repository.mock.ts
+++ b/server/test/repositories/storage.repository.mock.ts
@@ -1,6 +1,6 @@
 import { WatchOptions } from 'chokidar';
 import { StorageCore } from 'src/cores/storage.core';
-import { IStorageRepository, StorageEventType, WatchEvents } from 'src/interfaces/storage.interface';
+import { IStorageRepository, WatchEvents } from 'src/interfaces/storage.interface';
 
 interface MockWatcherOptions {
   items?: Array<{ event: 'change' | 'add' | 'unlink' | 'error'; value: string }>;
@@ -13,19 +13,19 @@ export const makeMockWatcher =
     events.onReady?.();
     for (const item of items || []) {
       switch (item.event) {
-        case StorageEventType.ADD: {
+        case 'add': {
           events.onAdd?.(item.value);
           break;
         }
-        case StorageEventType.CHANGE: {
+        case 'change': {
           events.onChange?.(item.value);
           break;
         }
-        case StorageEventType.UNLINK: {
+        case 'unlink': {
           events.onUnlink?.(item.value);
           break;
         }
-        case StorageEventType.ERROR: {
+        case 'error': {
           events.onError?.(new Error(item.value));
         }
       }
diff --git a/server/test/utils.ts b/server/test/utils.ts
deleted file mode 100644
index c7732eabc1..0000000000
--- a/server/test/utils.ts
+++ /dev/null
@@ -1,168 +0,0 @@
-import { INestApplication } from '@nestjs/common';
-import { Test } from '@nestjs/testing';
-import { DateTime } from 'luxon';
-import fs from 'node:fs';
-import { tmpdir } from 'node:os';
-import { join } from 'node:path';
-import { EventEmitter } from 'node:stream';
-import { AppTestModule } from 'src/app.module';
-import { dataSource } from 'src/database.config';
-import { IJobRepository, JobItem, JobItemHandler, QueueName } from 'src/interfaces/job.interface';
-import { IMediaRepository } from 'src/interfaces/media.interface';
-import { StorageEventType } from 'src/interfaces/storage.interface';
-import { MediaRepository } from 'src/repositories/media.repository';
-import { ApiService } from 'src/services/api.service';
-import { MicroservicesService } from 'src/services/microservices.service';
-import { EntityTarget, ObjectLiteral } from 'typeorm';
-
-export const IMMICH_TEST_ASSET_PATH = process.env.IMMICH_TEST_ASSET_PATH as string;
-export const IMMICH_TEST_ASSET_TEMP_PATH = join(tmpdir(), 'immich');
-
-export const today = DateTime.fromObject({ year: 2023, month: 11, day: 3 });
-export const yesterday = today.minus({ days: 1 });
-
-export interface ResetOptions {
-  entities?: EntityTarget<ObjectLiteral>[];
-}
-export const db = {
-  reset: async (options?: ResetOptions) => {
-    if (!dataSource.isInitialized) {
-      await dataSource.initialize();
-    }
-    await dataSource.transaction(async (em) => {
-      const entities = options?.entities || [];
-      const tableNames =
-        entities.length > 0
-          ? entities.map((entity) => em.getRepository(entity).metadata.tableName)
-          : dataSource.entityMetadatas
-              .map((entity) => entity.tableName)
-              .filter((tableName) => !tableName.startsWith('geodata'));
-
-      let deleteUsers = false;
-      for (const tableName of tableNames) {
-        if (tableName === 'users') {
-          deleteUsers = true;
-          continue;
-        }
-        await em.query(`DELETE FROM ${tableName} CASCADE;`);
-      }
-      if (deleteUsers) {
-        await em.query(`DELETE FROM "users" CASCADE;`);
-      }
-
-      // Release all locks
-      await em.query('SELECT pg_advisory_unlock_all()');
-    });
-  },
-  disconnect: async () => {
-    if (dataSource.isInitialized) {
-      await dataSource.destroy();
-    }
-  },
-};
-
-class JobMock implements IJobRepository {
-  private _handler: JobItemHandler = () => Promise.resolve();
-  addHandler(_queueName: QueueName, _concurrency: number, handler: JobItemHandler) {
-    this._handler = handler;
-  }
-  addCronJob() {}
-  updateCronJob() {}
-  deleteCronJob() {}
-  validateCronExpression() {}
-  queue(item: JobItem) {
-    return this._handler(item);
-  }
-  queueAll(items: JobItem[]) {
-    return Promise.all(items.map((arg) => this._handler(arg))).then(() => {});
-  }
-  async resume() {}
-  async empty() {}
-  async setConcurrency() {}
-  getQueueStatus() {
-    return Promise.resolve(null) as any;
-  }
-  getJobCounts() {
-    return Promise.resolve(null) as any;
-  }
-  async pause() {}
-  clear() {
-    return Promise.resolve([]);
-  }
-  async waitForQueueCompletion() {}
-}
-
-class MediaMockRepository extends MediaRepository {
-  generateThumbhash() {
-    return Promise.resolve(Buffer.from('mock-thumbhash'));
-  }
-}
-
-let app: INestApplication;
-
-export const testApp = {
-  create: async (): Promise<INestApplication> => {
-    const moduleFixture = await Test.createTestingModule({ imports: [AppTestModule] })
-      .overrideProvider(IJobRepository)
-      .useClass(JobMock)
-      .overrideProvider(IMediaRepository)
-      .useClass(MediaMockRepository)
-      .compile();
-
-    app = await moduleFixture.createNestApplication().init();
-    await app.get(ApiService).init();
-    await db.reset();
-    await app.get(ApiService).init();
-    await app.get(MicroservicesService).init();
-
-    return app;
-  },
-  reset: async (options?: ResetOptions) => {
-    await db.reset(options);
-  },
-  get: (member: any) => app.get(member),
-  teardown: async () => {
-    if (app) {
-      await app.get(MicroservicesService).teardown();
-      await app.close();
-    }
-    await db.disconnect();
-  },
-};
-
-export function waitForEvent(emitter: EventEmitter, event: string, times = 1): Promise<void[]> {
-  const promises: Promise<void>[] = [];
-
-  for (let i = 1; i <= times; i++) {
-    promises.push(
-      new Promise((resolve, reject) => {
-        const success = (value: any) => {
-          emitter.off(StorageEventType.ERROR, fail);
-          resolve(value);
-        };
-        const fail = (error: Error) => {
-          emitter.off(event, success);
-          reject(error);
-        };
-        emitter.once(event, success);
-        emitter.once(StorageEventType.ERROR, fail);
-      }),
-    );
-  }
-  return Promise.all(promises);
-}
-
-const directoryExists = async (dirPath: string) =>
-  await fs.promises
-    .access(dirPath)
-    .then(() => true)
-    .catch(() => false);
-
-export async function restoreTempFolder(): Promise<void> {
-  if (await directoryExists(`${IMMICH_TEST_ASSET_TEMP_PATH}`)) {
-    // Temp directory exists, delete all files inside it
-    await fs.promises.rm(IMMICH_TEST_ASSET_TEMP_PATH, { recursive: true });
-  }
-  // Create temp folder
-  await fs.promises.mkdir(IMMICH_TEST_ASSET_TEMP_PATH);
-}