فهرست منبع

test(server): full backend end-to-end testing with microservices (#4225)

* feat: asset e2e with job option

* feat: checkout test assets

* feat: library e2e tests

* fix: use node 21 in e2e

* fix: tests

* fix: use normalized external path

* feat: more external path tests

* chore: use parametrized tests

* chore: remove unused test code

* chore: refactor test asset path

* feat: centralize test app creation

* fix: correct error message for missing assets

* feat: test file formats

* fix: don't compare checksum

* feat: build libvips

* fix: install meson

* fix: use immich test asset repo

* feat: test nikon raw files

* fix: set Z timezone

* feat: test offline library files

* feat: richer metadata tests

* feat: e2e tests in docker

* feat: e2e test with arm64 docker

* fix: manual docker compose run

* fix: remove metadata processor import

* fix: run e2e tests in test.yml

* fix: checkout e2e assets

* fix: typo

* fix: checkout files in app directory

* fix: increase e2e memory

* fix: rm submodules

* fix: revert action name

* test: mark file offline when external path changes

* feat: rename env var to TEST_ENV

* docs: new test procedures

* feat: can run docker e2e tests manually if needed

* chore: use new node 20.8 for e2e

* chore: bump exiftool-vendored

* feat: simplify test launching

* fix: rename env vars to use immich_ prefix

* feat: asset folder is submodule

* chore: cleanup after 20.8 upgrade

* fix: don't log postgres in e2e

* fix: better warning about not running all tests

---------

Co-authored-by: Jonathan Jogenfors <jonathan@jogenfors.se>
Jason Rasmussen 1 سال پیش
والد
کامیت
8d5bf93360

+ 3 - 8
.github/workflows/test.yml

@@ -13,20 +13,15 @@ jobs:
   e2e-tests:
   e2e-tests:
     name: Run end-to-end test suites
     name: Run end-to-end test suites
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
-    defaults:
-      run:
-        working-directory: ./server
 
 
     steps:
     steps:
       - name: Checkout code
       - name: Checkout code
         uses: actions/checkout@v4
         uses: actions/checkout@v4
-
-      - name: Run npm install
-        run: npm ci
+        with:
+          submodules: "recursive"
 
 
       - name: Run e2e tests
       - name: Run e2e tests
-        run: npm run test:e2e
-        if: ${{ !cancelled() }}
+        run: docker-compose -f ./docker/docker-compose.test.yml -p immich-test-e2e up --renew-anon-volumes --abort-on-container-exit --exit-code-from immich-server-test --remove-orphans --build
 
 
   doc-tests:
   doc-tests:
     name: Run documentation checks
     name: Run documentation checks

+ 3 - 0
.gitmodules

@@ -1,3 +1,6 @@
 [submodule "mobile/.isar"]
 [submodule "mobile/.isar"]
 	path = mobile/.isar
 	path = mobile/.isar
 	url = https://github.com/isar/isar
 	url = https://github.com/isar/isar
+[submodule "server/test/assets"]
+	path = server/test/assets
+	url = https://github.com/immich-app/test-assets

+ 1 - 1
Makefile

@@ -20,7 +20,7 @@ pull-stage:
 	docker-compose -f ./docker/docker-compose.staging.yml pull
 	docker-compose -f ./docker/docker-compose.staging.yml pull
 
 
 test-e2e:
 test-e2e:
-	docker-compose -f ./docker/docker-compose.test.yml --env-file ./docker/.env.test -p immich-test-e2e up  --renew-anon-volumes --abort-on-container-exit --exit-code-from immich-server-test --remove-orphans --build
+	docker-compose -f ./docker/docker-compose.test.yml -p immich-test-e2e up  --renew-anon-volumes --abort-on-container-exit --exit-code-from immich-server-test --remove-orphans --build
 
 
 prod:
 prod:
 	docker-compose -f ./docker/docker-compose.prod.yml up --build -V --remove-orphans
 	docker-compose -f ./docker/docker-compose.prod.yml up --build -V --remove-orphans

+ 0 - 16
docker/.env.test

@@ -1,16 +0,0 @@
-# Database
-DB_HOSTNAME=immich-database-test
-DB_USERNAME=postgres
-DB_PASSWORD=postgres
-DB_DATABASE_NAME=e2e_test
-
-# Redis
-REDIS_HOSTNAME=immich-redis-test
-
-# Upload File Config
-UPLOAD_LOCATION=./upload
-
-# WEB
-VITE_SERVER_ENDPOINT=http://localhost:2283/api
-
-TYPESENSE_ENABLED=false

+ 13 - 19
docker/docker-compose.test.yml

@@ -1,5 +1,7 @@
 version: "3.8"
 version: "3.8"
 
 
+# Compose file for dockerized end-to-end testing of the backend
+
 services:
 services:
   immich-server-test:
   immich-server-test:
     image: immich-server-test
     image: immich-server-test
@@ -8,39 +10,31 @@ services:
       dockerfile: Dockerfile
       dockerfile: Dockerfile
       target: builder
       target: builder
     command: npm run test:e2e
     command: npm run test:e2e
-    expose:
-      - "3000"
     volumes:
     volumes:
       - ../server:/usr/src/app
       - ../server:/usr/src/app
       - /usr/src/app/node_modules
       - /usr/src/app/node_modules
-    env_file:
-      - .env.test
     environment:
     environment:
-      - NODE_ENV=development
-      - TYPESENSE_ENABLED=false
+      - DB_HOSTNAME=immich-database-test
+      - DB_USERNAME=postgres
+      - DB_PASSWORD=postgres
+      - DB_DATABASE_NAME=e2e_test
+      - IMMICH_RUN_ALL_TESTS=true
     depends_on:
     depends_on:
-      - immich-redis-test
       - immich-database-test
       - immich-database-test
     networks:
     networks:
       - immich-test-network
       - immich-test-network
-  immich-redis-test:
-    container_name: immich-redis-test
-    image: redis:6.2-alpine@sha256:70a7a5b641117670beae0d80658430853896b5ef269ccf00d1827427e3263fa3
-    networks:
-      - immich-test-network
+
   immich-database-test:
   immich-database-test:
     container_name: immich-database-test
     container_name: immich-database-test
     image: postgres:14-alpine@sha256:28407a9961e76f2d285dc6991e8e48893503cc3836a4755bbc2d40bcc272a441
     image: postgres:14-alpine@sha256:28407a9961e76f2d285dc6991e8e48893503cc3836a4755bbc2d40bcc272a441
-    env_file:
-      - .env.test
     environment:
     environment:
-      POSTGRES_PASSWORD: ${DB_PASSWORD}
-      POSTGRES_USER: ${DB_USERNAME}
-      POSTGRES_DB: ${DB_DATABASE_NAME}
-    volumes:
-      - /var/lib/postgresql/data
+      POSTGRES_PASSWORD: postgres
+      POSTGRES_USER: postgres
+      POSTGRES_DB: e2e_test
     networks:
     networks:
       - immich-test-network
       - immich-test-network
+    logging:
+      driver: none
 
 
 networks:
 networks:
   immich-test-network:
   immich-test-network:

+ 17 - 0
docs/docs/developer/testing.md

@@ -0,0 +1,17 @@
+# Testing
+
+## Server
+
+### Unit tests
+
+Unit are run by calling `npm run test` from the `server` directory.
+
+### End to end tests
+
+The backend has an end-to-end test suite that can be called with `npm run test:e2e` from the `server` directory. This will set up a dummy database inside a temporary container and run the tests against it. Setup and teardown is automatically taken care of. That test, however, can not set up all prerequisites to parse file formats, as that is very complex and error-prone. As such, this test excludes some test cases like HEIC file imports. The test suite will also print a friendly warning to remind you that not all tests are being run.
+
+Note that there is a bug in nodejs <20.8 that causes segmentation faults when running these tests. If you run into segfaults, ensure you are using at least version 20.8.
+
+To perform a full e2e test, you need to run e2e tests inside docker. The easiest way to do that is to run `make test-e2e` in the root directory. This will build and start a docker-compose consisting of the server, microservices, and a postgres database. It will then perfom the tests and exit.
+
+If you manually install the dependencies (see the DOCKERFILE) on your development machine, you can also run the full e2e tests manually by setting the `IMMICH_RUN_ALL_TESTS` environment value to true, i.e. `IMMICH_RUN_ALL_TESTS=true npm run test:e2e`.

+ 57 - 1
server/package-lock.json

@@ -100,7 +100,8 @@
         "ts-loader": "^9.4.4",
         "ts-loader": "^9.4.4",
         "ts-node": "^10.9.1",
         "ts-node": "^10.9.1",
         "tsconfig-paths": "^4.2.0",
         "tsconfig-paths": "^4.2.0",
-        "typescript": "^5.2.2"
+        "typescript": "^5.2.2",
+        "utimes": "^5.2.1"
       }
       }
     },
     },
     "node_modules/@aashutoshrathi/word-wrap": {
     "node_modules/@aashutoshrathi/word-wrap": {
@@ -6857,6 +6858,15 @@
         "!win32"
         "!win32"
       ]
       ]
     },
     },
+    "node_modules/exiftool-vendored/node_modules/exiftool-vendored.pl": {
+      "version": "12.67.0",
+      "resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.67.0.tgz",
+      "integrity": "sha512-Jvjkv4Cad+Bnp/4PuLEhO2BSpKy0MBccmq8if/H8V2ykssZrpUh8DRwEJkONnsaNX7dqKfObbOFig3vwoDyXsA==",
+      "optional": true,
+      "os": [
+        "!win32"
+      ]
+    },
     "node_modules/exit": {
     "node_modules/exit": {
       "version": "0.1.2",
       "version": "0.1.2",
       "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz",
       "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz",
@@ -13789,6 +13799,26 @@
         "node": ">= 0.4.0"
         "node": ">= 0.4.0"
       }
       }
     },
     },
+    "node_modules/utimes": {
+      "version": "5.2.1",
+      "resolved": "https://registry.npmjs.org/utimes/-/utimes-5.2.1.tgz",
+      "integrity": "sha512-6S5mCapmzcxetOD/2UEjL0GF5e4+gB07Dh8qs63xylw5ay4XuyW6iQs70FOJo/puf10LCkvhp4jYMQSDUBYEFg==",
+      "dev": true,
+      "hasInstallScript": true,
+      "dependencies": {
+        "@mapbox/node-pre-gyp": "^1.0.11",
+        "node-addon-api": "^4.3.0"
+      },
+      "engines": {
+        "node": ">=10.0.0"
+      }
+    },
+    "node_modules/utimes/node_modules/node-addon-api": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-4.3.0.tgz",
+      "integrity": "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==",
+      "dev": true
+    },
     "node_modules/uuid": {
     "node_modules/uuid": {
       "version": "9.0.0",
       "version": "9.0.0",
       "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz",
       "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz",
@@ -19202,6 +19232,14 @@
         "exiftool-vendored.pl": "12.67.0",
         "exiftool-vendored.pl": "12.67.0",
         "he": "^1.2.0",
         "he": "^1.2.0",
         "luxon": "^3.4.3"
         "luxon": "^3.4.3"
+      },
+      "dependencies": {
+        "exiftool-vendored.pl": {
+          "version": "12.67.0",
+          "resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.67.0.tgz",
+          "integrity": "sha512-Jvjkv4Cad+Bnp/4PuLEhO2BSpKy0MBccmq8if/H8V2ykssZrpUh8DRwEJkONnsaNX7dqKfObbOFig3vwoDyXsA==",
+          "optional": true
+        }
       }
       }
     },
     },
     "exiftool-vendored.exe": {
     "exiftool-vendored.exe": {
@@ -24286,6 +24324,24 @@
       "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
       "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
       "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="
       "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="
     },
     },
+    "utimes": {
+      "version": "5.2.1",
+      "resolved": "https://registry.npmjs.org/utimes/-/utimes-5.2.1.tgz",
+      "integrity": "sha512-6S5mCapmzcxetOD/2UEjL0GF5e4+gB07Dh8qs63xylw5ay4XuyW6iQs70FOJo/puf10LCkvhp4jYMQSDUBYEFg==",
+      "dev": true,
+      "requires": {
+        "@mapbox/node-pre-gyp": "^1.0.11",
+        "node-addon-api": "^4.3.0"
+      },
+      "dependencies": {
+        "node-addon-api": {
+          "version": "4.3.0",
+          "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-4.3.0.tgz",
+          "integrity": "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==",
+          "dev": true
+        }
+      }
+    },
     "uuid": {
     "uuid": {
       "version": "9.0.0",
       "version": "9.0.0",
       "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz",
       "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz",

+ 3 - 2
server/package.json

@@ -26,7 +26,7 @@
     "test:watch": "jest --watch",
     "test:watch": "jest --watch",
     "test:cov": "jest --coverage",
     "test:cov": "jest --coverage",
     "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
     "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
-    "test:e2e": "jest --config test/e2e/jest-e2e.json --runInBand",
+    "test:e2e": "NODE_OPTIONS='--experimental-vm-modules --max_old_space_size=4096' jest --config test/e2e/jest-e2e.json --runInBand --forceExit",
     "typeorm": "typeorm",
     "typeorm": "typeorm",
     "typeorm:migrations:create": "typeorm migration:create",
     "typeorm:migrations:create": "typeorm migration:create",
     "typeorm:migrations:generate": "typeorm migration:generate -d ./dist/infra/database.config.js",
     "typeorm:migrations:generate": "typeorm migration:generate -d ./dist/infra/database.config.js",
@@ -126,7 +126,8 @@
     "ts-loader": "^9.4.4",
     "ts-loader": "^9.4.4",
     "ts-node": "^10.9.1",
     "ts-node": "^10.9.1",
     "tsconfig-paths": "^4.2.0",
     "tsconfig-paths": "^4.2.0",
-    "typescript": "^5.2.2"
+    "typescript": "^5.2.2",
+    "utimes": "^5.2.1"
   },
   },
   "jest": {
   "jest": {
     "clearMocks": true,
     "clearMocks": true,

+ 2 - 1
server/src/domain/job/job.repository.ts

@@ -107,11 +107,12 @@ export type JobItem =
   | { name: JobName.SEARCH_REMOVE_FACE; data: IAssetFaceJob };
   | { name: JobName.SEARCH_REMOVE_FACE; data: IAssetFaceJob };
 
 
 export type JobHandler<T = any> = (data: T) => boolean | Promise<boolean>;
 export type JobHandler<T = any> = (data: T) => boolean | Promise<boolean>;
+export type JobItemHandler = (item: JobItem) => Promise<void>;
 
 
 export const IJobRepository = 'IJobRepository';
 export const IJobRepository = 'IJobRepository';
 
 
 export interface IJobRepository {
 export interface IJobRepository {
-  addHandler(queueName: QueueName, concurrency: number, handler: (job: JobItem) => Promise<void>): void;
+  addHandler(queueName: QueueName, concurrency: number, handler: JobItemHandler): void;
   setConcurrency(queueName: QueueName, concurrency: number): void;
   setConcurrency(queueName: QueueName, concurrency: number): void;
   queue(item: JobItem): Promise<void>;
   queue(item: JobItem): Promise<void>;
   pause(name: QueueName): Promise<void>;
   pause(name: QueueName): Promise<void>;

+ 1 - 1
server/src/domain/library/library.service.spec.ts

@@ -1172,7 +1172,7 @@ describe(LibraryService.name, () => {
     });
     });
   });
   });
 
 
-  describe('handleEmptyTrash', () => {
+  describe('handleRemoveOfflineFiles', () => {
     it('can queue trash deletion jobs', async () => {
     it('can queue trash deletion jobs', async () => {
       assetMock.getWith.mockResolvedValue({ items: [assetStub.image1], hasNextPage: false });
       assetMock.getWith.mockResolvedValue({ items: [assetStub.image1], hasNextPage: false });
       assetMock.getById.mockResolvedValue(assetStub.image1);
       assetMock.getById.mockResolvedValue(assetStub.image1);

+ 3 - 1
server/src/domain/library/library.service.ts

@@ -363,6 +363,8 @@ export class LibraryService {
       return false;
       return false;
     }
     }
 
 
+    const normalizedExternalPath = path.normalize(user.externalPath);
+
     this.logger.verbose(`Refreshing library: ${job.id}`);
     this.logger.verbose(`Refreshing library: ${job.id}`);
     const crawledAssetPaths = (
     const crawledAssetPaths = (
       await this.storageRepository.crawl({
       await this.storageRepository.crawl({
@@ -373,7 +375,7 @@ export class LibraryService {
       .map(path.normalize)
       .map(path.normalize)
       .filter((assetPath) =>
       .filter((assetPath) =>
         // Filter out paths that are not within the user's external path
         // Filter out paths that are not within the user's external path
-        assetPath.match(new RegExp(`^${user.externalPath}`)),
+        assetPath.match(new RegExp(`^${normalizedExternalPath}`)),
       );
       );
 
 
     this.logger.debug(`Found ${crawledAssetPaths.length} assets when crawling import paths ${library.importPaths}`);
     this.logger.debug(`Found ${crawledAssetPaths.length} assets when crawling import paths ${library.importPaths}`);

+ 1 - 1
server/src/immich/api-v1/asset/asset.service.ts

@@ -119,7 +119,7 @@ export class AssetService {
       }
       }
 
 
       this.logger.error(`Error uploading file ${error}`, error?.stack);
       this.logger.error(`Error uploading file ${error}`, error?.stack);
-      throw new BadRequestException(`Error uploading file`, `${error}`);
+      throw error;
     }
     }
   }
   }
 
 

+ 4 - 0
server/src/infra/infra.config.ts

@@ -5,6 +5,10 @@ import { RedisOptions } from 'ioredis';
 import { ConfigurationOptions } from 'typesense/lib/Typesense/Configuration';
 import { ConfigurationOptions } from 'typesense/lib/Typesense/Configuration';
 
 
 function parseRedisConfig(): RedisOptions {
 function parseRedisConfig(): RedisOptions {
+  if (process.env.IMMICH_TEST_ENV == 'true') {
+    return {};
+  }
+
   const redisUrl = process.env.REDIS_URL;
   const redisUrl = process.env.REDIS_URL;
   if (redisUrl && redisUrl.startsWith('ioredis://')) {
   if (redisUrl && redisUrl.startsWith('ioredis://')) {
     try {
     try {

+ 16 - 8
server/src/infra/infra.module.ts

@@ -80,16 +80,24 @@ const providers: Provider[] = [
   { provide: IUserTokenRepository, useClass: UserTokenRepository },
   { provide: IUserTokenRepository, useClass: UserTokenRepository },
 ];
 ];
 
 
+const imports = [
+  ConfigModule.forRoot(immichAppConfig),
+  TypeOrmModule.forRoot(databaseConfig),
+  TypeOrmModule.forFeature(databaseEntities),
+];
+
+const moduleExports = [...providers];
+
+if (process.env.IMMICH_TEST_ENV !== 'true') {
+  imports.push(BullModule.forRoot(bullConfig));
+  imports.push(BullModule.registerQueue(...bullQueues));
+  moduleExports.push(BullModule);
+}
+
 @Global()
 @Global()
 @Module({
 @Module({
-  imports: [
-    ConfigModule.forRoot(immichAppConfig),
-    TypeOrmModule.forRoot(databaseConfig),
-    TypeOrmModule.forFeature(databaseEntities),
-    BullModule.forRoot(bullConfig),
-    BullModule.registerQueue(...bullQueues),
-  ],
+  imports,
   providers: [...providers],
   providers: [...providers],
-  exports: [...providers, BullModule],
+  exports: moduleExports,
 })
 })
 export class InfraModule {}
 export class InfraModule {}

+ 6 - 1
server/test/api/asset-api.ts

@@ -7,13 +7,18 @@ import request from 'supertest';
 type UploadDto = Partial<CreateAssetDto> & { content?: Buffer };
 type UploadDto = Partial<CreateAssetDto> & { content?: Buffer };
 
 
 export const assetApi = {
 export const assetApi = {
-  get: async (server: any, accessToken: string, id: string) => {
+  get: async (server: any, accessToken: string, id: string): Promise<AssetResponseDto> => {
     const { body, status } = await request(server)
     const { body, status } = await request(server)
       .get(`/asset/assetById/${id}`)
       .get(`/asset/assetById/${id}`)
       .set('Authorization', `Bearer ${accessToken}`);
       .set('Authorization', `Bearer ${accessToken}`);
     expect(status).toBe(200);
     expect(status).toBe(200);
     return body as AssetResponseDto;
     return body as AssetResponseDto;
   },
   },
+  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[];
+  },
   upload: async (server: any, accessToken: string, id: string, dto: UploadDto = {}) => {
   upload: async (server: any, accessToken: string, id: string, dto: UploadDto = {}) => {
     const { content, isFavorite = false, isArchived = false } = dto;
     const { content, isFavorite = false, isArchived = false } = dto;
     const { body, status } = await request(server)
     const { body, status } = await request(server)

+ 38 - 1
server/test/api/library-api.ts

@@ -1,4 +1,4 @@
-import { LibraryResponseDto } from '@app/domain';
+import { CreateLibraryDto, LibraryResponseDto, LibraryStatsResponseDto, ScanLibraryDto } from '@app/domain';
 import request from 'supertest';
 import request from 'supertest';
 
 
 export const libraryApi = {
 export const libraryApi = {
@@ -7,4 +7,41 @@ export const libraryApi = {
     expect(status).toBe(200);
     expect(status).toBe(200);
     return body as LibraryResponseDto[];
     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(201);
+  },
+  removeOfflineFiles: async (server: any, accessToken: string, id: string) => {
+    const { status } = await request(server)
+      .post(`/library/${id}/removeOffline`)
+      .set('Authorization', `Bearer ${accessToken}`)
+      .send();
+    expect(status).toBe(201);
+  },
+  getLibraryStatistics: async (server: any, accessToken: string, id: string): Promise<LibraryStatsResponseDto> => {
+    const { body, status } = await request(server)
+      .get(`/library/${id}/statistics`)
+      .set('Authorization', `Bearer ${accessToken}`);
+    expect(status).toBe(200);
+    return body;
+  },
 };
 };

+ 3 - 0
server/test/api/user-api.ts

@@ -36,6 +36,9 @@ export const userApi = {
 
 
     return body as UserResponseDto;
     return body as UserResponseDto;
   },
   },
+  setExternalPath: async (server: any, accessToken: string, id: string, externalPath: string) => {
+    return await userApi.update(server, accessToken, { id, externalPath });
+  },
   delete: async (server: any, accessToken: string, id: string) => {
   delete: async (server: any, accessToken: string, id: string) => {
     const { status, body } = await request(server).delete(`/user/${id}`).set('Authorization', `Bearer ${accessToken}`);
     const { status, body } = await request(server).delete(`/user/${id}`).set('Authorization', `Bearer ${accessToken}`);
 
 

+ 3 - 6
server/test/e2e/album.e2e-spec.ts

@@ -1,12 +1,12 @@
 import { AlbumResponseDto, LoginResponseDto } from '@app/domain';
 import { AlbumResponseDto, LoginResponseDto } from '@app/domain';
-import { AlbumController, AppModule } from '@app/immich';
+import { AlbumController } from '@app/immich';
 import { AssetFileUploadResponseDto } from '@app/immich/api-v1/asset/response-dto/asset-file-upload-response.dto';
 import { AssetFileUploadResponseDto } from '@app/immich/api-v1/asset/response-dto/asset-file-upload-response.dto';
 import { SharedLinkType } from '@app/infra/entities';
 import { SharedLinkType } from '@app/infra/entities';
 import { INestApplication } from '@nestjs/common';
 import { INestApplication } from '@nestjs/common';
-import { Test, TestingModule } from '@nestjs/testing';
 import { api } from '@test/api';
 import { api } from '@test/api';
 import { db } from '@test/db';
 import { db } from '@test/db';
 import { errorStub, uuidStub } from '@test/fixtures';
 import { errorStub, uuidStub } from '@test/fixtures';
+import { createTestApp } from '@test/test-utils';
 import request from 'supertest';
 import request from 'supertest';
 
 
 const user1SharedUser = 'user1SharedUser';
 const user1SharedUser = 'user1SharedUser';
@@ -27,11 +27,8 @@ describe(`${AlbumController.name} (e2e)`, () => {
   let user2Albums: AlbumResponseDto[];
   let user2Albums: AlbumResponseDto[];
 
 
   beforeAll(async () => {
   beforeAll(async () => {
-    const moduleFixture: TestingModule = await Test.createTestingModule({
-      imports: [AppModule],
-    }).compile();
+    app = await createTestApp();
 
 
-    app = await moduleFixture.createNestApplication().init();
     server = app.getHttpServer();
     server = app.getHttpServer();
   });
   });
 
 

+ 24 - 7
server/test/e2e/asset.e2e-spec.ts

@@ -6,13 +6,12 @@ import {
   LoginResponseDto,
   LoginResponseDto,
   TimeBucketSize,
   TimeBucketSize,
 } from '@app/domain';
 } from '@app/domain';
-import { AppModule, AssetController } from '@app/immich';
+import { AssetController } from '@app/immich';
 import { AssetEntity, AssetType } from '@app/infra/entities';
 import { AssetEntity, AssetType } from '@app/infra/entities';
 import { INestApplication } from '@nestjs/common';
 import { INestApplication } from '@nestjs/common';
-import { Test, TestingModule } from '@nestjs/testing';
 import { api } from '@test/api';
 import { api } from '@test/api';
-import { db } from '@test/db';
 import { errorStub, uuidStub } from '@test/fixtures';
 import { errorStub, uuidStub } from '@test/fixtures';
+import { createTestApp, db } from '@test/test-utils';
 import { randomBytes } from 'crypto';
 import { randomBytes } from 'crypto';
 import request from 'supertest';
 import request from 'supertest';
 
 
@@ -85,11 +84,8 @@ describe(`${AssetController.name} (e2e)`, () => {
   let asset4: AssetEntity;
   let asset4: AssetEntity;
 
 
   beforeAll(async () => {
   beforeAll(async () => {
-    const moduleFixture: TestingModule = await Test.createTestingModule({
-      imports: [AppModule],
-    }).compile();
+    app = await createTestApp();
 
 
-    app = await moduleFixture.createNestApplication().init();
     server = app.getHttpServer();
     server = app.getHttpServer();
     assetRepository = app.get<IAssetRepository>(IAssetRepository);
     assetRepository = app.get<IAssetRepository>(IAssetRepository);
   });
   });
@@ -200,6 +196,27 @@ describe(`${AssetController.name} (e2e)`, () => {
       expect(status).toBe(200);
       expect(status).toBe(200);
       expect(body.duplicate).toBe(true);
       expect(body.duplicate).toBe(true);
     });
     });
+
+    it("should not upload to another user's library", async () => {
+      const content = randomBytes(32);
+      const library = (await api.libraryApi.getAll(server, user2.accessToken))[0];
+      await api.assetApi.upload(server, user1.accessToken, 'example-image', { content });
+
+      const { body, status } = await request(server)
+        .post('/asset/upload')
+        .set('Authorization', `Bearer ${user1.accessToken}`)
+        .field('libraryId', library.id)
+        .field('deviceAssetId', 'example-image')
+        .field('deviceId', 'TEST')
+        .field('fileCreatedAt', new Date().toISOString())
+        .field('fileModifiedAt', new Date().toISOString())
+        .field('isFavorite', false)
+        .field('duration', '0:00:00.000000')
+        .attach('assetData', content, 'example.jpg');
+
+      expect(status).toBe(400);
+      expect(body).toEqual(errorStub.badRequest('Not found or no asset.upload access'));
+    });
   });
   });
 
 
   describe('PUT /asset/:id', () => {
   describe('PUT /asset/:id', () => {

+ 3 - 7
server/test/e2e/auth.e2e-spec.ts

@@ -1,6 +1,5 @@
-import { AppModule, AuthController } from '@app/immich';
+import { AuthController } from '@app/immich';
 import { INestApplication } from '@nestjs/common';
 import { INestApplication } from '@nestjs/common';
-import { Test, TestingModule } from '@nestjs/testing';
 import { api } from '@test/api';
 import { api } from '@test/api';
 import { db } from '@test/db';
 import { db } from '@test/db';
 import {
 import {
@@ -13,6 +12,7 @@ import {
   signupResponseStub,
   signupResponseStub,
   uuidStub,
   uuidStub,
 } from '@test/fixtures';
 } from '@test/fixtures';
+import { createTestApp } from '@test/test-utils';
 import request from 'supertest';
 import request from 'supertest';
 
 
 const firstName = 'Immich';
 const firstName = 'Immich';
@@ -26,11 +26,7 @@ describe(`${AuthController.name} (e2e)`, () => {
   let accessToken: string;
   let accessToken: string;
 
 
   beforeAll(async () => {
   beforeAll(async () => {
-    const moduleFixture: TestingModule = await Test.createTestingModule({
-      imports: [AppModule],
-    }).compile();
-
-    app = await moduleFixture.createNestApplication().init();
+    app = await createTestApp();
     server = app.getHttpServer();
     server = app.getHttpServer();
   });
   });
 
 

+ 206 - 0
server/test/e2e/formats.e2e-spec.ts

@@ -0,0 +1,206 @@
+import { LoginResponseDto } from '@app/domain';
+import { AssetType, LibraryType } from '@app/infra/entities';
+import { INestApplication } from '@nestjs/common';
+import { api } from '@test/api';
+import { IMMICH_TEST_ASSET_PATH, createTestApp, db, runAllTests } from '@test/test-utils';
+
+describe(`Supported file formats (e2e)`, () => {
+  let app: INestApplication;
+  let server: any;
+  let admin: LoginResponseDto;
+
+  interface FormatTest {
+    format: string;
+    path: string;
+    runTest: boolean;
+    expectedAsset: any;
+    expectedExif: any;
+  }
+
+  const formatTests: FormatTest[] = [
+    {
+      format: 'jpg',
+      path: 'jpg',
+      runTest: true,
+      expectedAsset: {
+        type: AssetType.IMAGE,
+        originalFileName: 'el_torcal_rocks',
+        resized: true,
+      },
+      expectedExif: {
+        dateTimeOriginal: '2012-08-05T11:39:59.000Z',
+        exifImageWidth: 512,
+        exifImageHeight: 341,
+        latitude: null,
+        longitude: null,
+        focalLength: 75,
+        iso: 200,
+        fNumber: 11,
+        exposureTime: '1/160',
+        fileSizeInByte: 53493,
+        make: 'SONY',
+        model: 'DSLR-A550',
+        orientation: null,
+      },
+    },
+    {
+      format: 'jpeg',
+      path: 'jpeg',
+      runTest: true,
+      expectedAsset: {
+        type: AssetType.IMAGE,
+        originalFileName: 'el_torcal_rocks',
+        resized: true,
+      },
+      expectedExif: {
+        dateTimeOriginal: '2012-08-05T11:39:59.000Z',
+        exifImageWidth: 512,
+        exifImageHeight: 341,
+        latitude: null,
+        longitude: null,
+        focalLength: 75,
+        iso: 200,
+        fNumber: 11,
+        exposureTime: '1/160',
+        fileSizeInByte: 53493,
+        make: 'SONY',
+        model: 'DSLR-A550',
+        orientation: null,
+      },
+    },
+    {
+      format: 'heic',
+      path: 'heic',
+      runTest: runAllTests,
+      expectedAsset: {
+        type: AssetType.IMAGE,
+        originalFileName: 'IMG_2682',
+        resized: true,
+        fileCreatedAt: '2019-03-21T16:04:22.348Z',
+      },
+      expectedExif: {
+        dateTimeOriginal: '2019-03-21T16:04:22.348Z',
+        exifImageWidth: 4032,
+        exifImageHeight: 3024,
+        latitude: 41.2203,
+        longitude: -96.071625,
+        make: 'Apple',
+        model: 'iPhone 7',
+        lensModel: 'iPhone 7 back camera 3.99mm f/1.8',
+        fileSizeInByte: 880703,
+        exposureTime: '1/887',
+        iso: 20,
+        focalLength: 3.99,
+        fNumber: 1.8,
+        state: 'Douglas County, Nebraska',
+        timeZone: 'America/Chicago',
+        city: 'Ralston',
+        country: 'United States of America',
+      },
+    },
+    {
+      format: 'png',
+      path: 'png',
+      runTest: true,
+      expectedAsset: {
+        type: AssetType.IMAGE,
+        originalFileName: 'density_plot',
+        resized: true,
+      },
+      expectedExif: {
+        exifImageWidth: 800,
+        exifImageHeight: 800,
+        latitude: null,
+        longitude: null,
+        fileSizeInByte: 25408,
+      },
+    },
+    {
+      format: 'nef (Nikon D80)',
+      path: 'raw/Nikon/D80',
+      runTest: true,
+      expectedAsset: {
+        type: AssetType.IMAGE,
+        originalFileName: 'glarus',
+        resized: true,
+        fileCreatedAt: '2010-07-20T17:27:12.000Z',
+      },
+      expectedExif: {
+        make: 'NIKON CORPORATION',
+        model: 'NIKON D80',
+        exposureTime: '1/200',
+        fNumber: 10,
+        focalLength: 18,
+        iso: 100,
+        fileSizeInByte: 9057784,
+        dateTimeOriginal: '2010-07-20T17:27:12.000Z',
+        latitude: null,
+        longitude: null,
+        orientation: '1',
+      },
+    },
+    {
+      format: 'nef (Nikon D700)',
+      path: 'raw/Nikon/D700',
+      runTest: true,
+      expectedAsset: {
+        type: AssetType.IMAGE,
+        originalFileName: 'philadelphia',
+        resized: true,
+        fileCreatedAt: '2016-09-22T22:10:29.060Z',
+      },
+      expectedExif: {
+        make: 'NIKON CORPORATION',
+        model: 'NIKON D700',
+        exposureTime: '1/400',
+        fNumber: 11,
+        focalLength: 85,
+        iso: 200,
+        fileSizeInByte: 15856335,
+        dateTimeOriginal: '2016-09-22T22:10:29.060Z',
+        latitude: null,
+        longitude: null,
+        orientation: '1',
+        timeZone: 'UTC-5',
+      },
+    },
+  ];
+
+  // Only run tests with runTest = true
+  const testsToRun = formatTests.filter((formatTest) => formatTest.runTest);
+
+  beforeAll(async () => {
+    app = await createTestApp(true);
+    server = app.getHttpServer();
+  });
+
+  beforeEach(async () => {
+    await db.reset();
+    await api.authApi.adminSignUp(server);
+    admin = await api.authApi.adminLogin(server);
+    await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, '/');
+  });
+
+  afterAll(async () => {
+    await db.disconnect();
+    await app.close();
+  });
+
+  it.each(testsToRun)('should import file of format $format', async (testedFormat) => {
+    const library = await api.libraryApi.create(server, admin.accessToken, {
+      type: LibraryType.EXTERNAL,
+      importPaths: [`${IMMICH_TEST_ASSET_PATH}/formats/${testedFormat.path}`],
+    });
+
+    await api.libraryApi.scanLibrary(server, admin.accessToken, library.id, {});
+
+    const assets = await api.assetApi.getAllAssets(server, admin.accessToken);
+
+    expect(assets).toEqual([
+      expect.objectContaining({
+        ...testedFormat.expectedAsset,
+        exifInfo: expect.objectContaining(testedFormat.expectedExif),
+      }),
+    ]);
+  });
+});

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 653 - 247
server/test/e2e/library.e2e-spec.ts


+ 3 - 7
server/test/e2e/oauth.e2e-spec.ts

@@ -1,9 +1,9 @@
-import { AppModule, OAuthController } from '@app/immich';
+import { OAuthController } from '@app/immich';
 import { INestApplication } from '@nestjs/common';
 import { INestApplication } from '@nestjs/common';
-import { Test, TestingModule } from '@nestjs/testing';
 import { api } from '@test/api';
 import { api } from '@test/api';
 import { db } from '@test/db';
 import { db } from '@test/db';
 import { errorStub } from '@test/fixtures';
 import { errorStub } from '@test/fixtures';
+import { createTestApp } from '@test/test-utils';
 import request from 'supertest';
 import request from 'supertest';
 
 
 describe(`${OAuthController.name} (e2e)`, () => {
 describe(`${OAuthController.name} (e2e)`, () => {
@@ -11,11 +11,7 @@ describe(`${OAuthController.name} (e2e)`, () => {
   let server: any;
   let server: any;
 
 
   beforeAll(async () => {
   beforeAll(async () => {
-    const moduleFixture: TestingModule = await Test.createTestingModule({
-      imports: [AppModule],
-    }).compile();
-
-    app = await moduleFixture.createNestApplication().init();
+    app = await createTestApp();
     server = app.getHttpServer();
     server = app.getHttpServer();
   });
   });
 
 

+ 3 - 7
server/test/e2e/partner.e2e-spec.ts

@@ -1,10 +1,10 @@
 import { IPartnerRepository, LoginResponseDto, PartnerDirection } from '@app/domain';
 import { IPartnerRepository, LoginResponseDto, PartnerDirection } from '@app/domain';
-import { AppModule, PartnerController } from '@app/immich';
+import { PartnerController } from '@app/immich';
 import { INestApplication } from '@nestjs/common';
 import { INestApplication } from '@nestjs/common';
-import { Test, TestingModule } from '@nestjs/testing';
 import { api } from '@test/api';
 import { api } from '@test/api';
 import { db } from '@test/db';
 import { db } from '@test/db';
 import { errorStub } from '@test/fixtures';
 import { errorStub } from '@test/fixtures';
+import { createTestApp } from '@test/test-utils';
 import request from 'supertest';
 import request from 'supertest';
 
 
 const user1Dto = {
 const user1Dto = {
@@ -31,11 +31,7 @@ describe(`${PartnerController.name} (e2e)`, () => {
   let user2: LoginResponseDto;
   let user2: LoginResponseDto;
 
 
   beforeAll(async () => {
   beforeAll(async () => {
-    const moduleFixture: TestingModule = await Test.createTestingModule({
-      imports: [AppModule],
-    }).compile();
-
-    app = await moduleFixture.createNestApplication().init();
+    app = await createTestApp();
     server = app.getHttpServer();
     server = app.getHttpServer();
     repository = app.get<IPartnerRepository>(IPartnerRepository);
     repository = app.get<IPartnerRepository>(IPartnerRepository);
   });
   });

+ 3 - 7
server/test/e2e/person.e2e-spec.ts

@@ -1,11 +1,11 @@
 import { IPersonRepository, LoginResponseDto } from '@app/domain';
 import { IPersonRepository, LoginResponseDto } from '@app/domain';
-import { AppModule, PersonController } from '@app/immich';
+import { PersonController } from '@app/immich';
 import { PersonEntity } from '@app/infra/entities';
 import { PersonEntity } from '@app/infra/entities';
 import { INestApplication } from '@nestjs/common';
 import { INestApplication } from '@nestjs/common';
-import { Test, TestingModule } from '@nestjs/testing';
 import { api } from '@test/api';
 import { api } from '@test/api';
 import { db } from '@test/db';
 import { db } from '@test/db';
 import { errorStub, uuidStub } from '@test/fixtures';
 import { errorStub, uuidStub } from '@test/fixtures';
+import { createTestApp } from '@test/test-utils';
 import request from 'supertest';
 import request from 'supertest';
 
 
 describe(`${PersonController.name}`, () => {
 describe(`${PersonController.name}`, () => {
@@ -18,11 +18,7 @@ describe(`${PersonController.name}`, () => {
   let hiddenPerson: PersonEntity;
   let hiddenPerson: PersonEntity;
 
 
   beforeAll(async () => {
   beforeAll(async () => {
-    const moduleFixture: TestingModule = await Test.createTestingModule({
-      imports: [AppModule],
-    }).compile();
-
-    app = await moduleFixture.createNestApplication().init();
+    app = await createTestApp();
     server = app.getHttpServer();
     server = app.getHttpServer();
     personRepository = app.get<IPersonRepository>(IPersonRepository);
     personRepository = app.get<IPersonRepository>(IPersonRepository);
   });
   });

+ 6 - 10
server/test/e2e/server-info.e2e-spec.ts

@@ -1,10 +1,10 @@
 import { LoginResponseDto } from '@app/domain';
 import { LoginResponseDto } from '@app/domain';
-import { AppModule, ServerInfoController } from '@app/immich';
+import { ServerInfoController } from '@app/immich';
 import { INestApplication } from '@nestjs/common';
 import { INestApplication } from '@nestjs/common';
-import { Test, TestingModule } from '@nestjs/testing';
 import { api } from '@test/api';
 import { api } from '@test/api';
 import { db } from '@test/db';
 import { db } from '@test/db';
 import { errorStub } from '@test/fixtures';
 import { errorStub } from '@test/fixtures';
+import { createTestApp } from '@test/test-utils';
 import request from 'supertest';
 import request from 'supertest';
 
 
 describe(`${ServerInfoController.name} (e2e)`, () => {
 describe(`${ServerInfoController.name} (e2e)`, () => {
@@ -14,11 +14,7 @@ describe(`${ServerInfoController.name} (e2e)`, () => {
   let loginResponse: LoginResponseDto;
   let loginResponse: LoginResponseDto;
 
 
   beforeAll(async () => {
   beforeAll(async () => {
-    const moduleFixture: TestingModule = await Test.createTestingModule({
-      imports: [AppModule],
-    }).compile();
-
-    app = await moduleFixture.createNestApplication().init();
+    app = await createTestApp();
     server = app.getHttpServer();
     server = app.getHttpServer();
   });
   });
 
 
@@ -81,9 +77,9 @@ describe(`${ServerInfoController.name} (e2e)`, () => {
       const { status, body } = await request(server).get('/server-info/features');
       const { status, body } = await request(server).get('/server-info/features');
       expect(status).toBe(200);
       expect(status).toBe(200);
       expect(body).toEqual({
       expect(body).toEqual({
-        clipEncode: true,
+        clipEncode: false,
         configFile: false,
         configFile: false,
-        facialRecognition: true,
+        facialRecognition: false,
         map: true,
         map: true,
         reverseGeocoding: true,
         reverseGeocoding: true,
         oauth: false,
         oauth: false,
@@ -91,7 +87,7 @@ describe(`${ServerInfoController.name} (e2e)`, () => {
         passwordLogin: true,
         passwordLogin: true,
         search: false,
         search: false,
         sidecar: true,
         sidecar: true,
-        tagImage: true,
+        tagImage: false,
         trash: true,
         trash: true,
       });
       });
     });
     });

+ 48 - 14
server/test/e2e/setup.ts

@@ -1,21 +1,55 @@
 import { PostgreSqlContainer } from '@testcontainers/postgresql';
 import { PostgreSqlContainer } from '@testcontainers/postgresql';
-import { GenericContainer } from 'testcontainers';
+import * as fs from 'fs';
+import path from 'path';
+
 export default async () => {
 export default async () => {
-  process.env.NODE_ENV = 'development';
-  process.env.TYPESENSE_ENABLED = 'false';
+  const allTests: boolean = process.env.IMMICH_RUN_ALL_TESTS === 'true';
+
+  if (!allTests) {
+    console.warn(
+      `\n\n
+      *** Not running all e2e tests. Run 'make test-e2e' to run all tests inside Docker (recommended)\n
+      *** or set 'IMMICH_RUN_ALL_TESTS=true' to run all tests(requires dependencies to be installed)\n`,
+    );
+  }
+
+  let IMMICH_TEST_ASSET_PATH: string = '';
 
 
-  const pg = await new PostgreSqlContainer('postgres')
-    .withExposedPorts(5432)
-    .withDatabase('immich')
-    .withUsername('postgres')
-    .withPassword('postgres')
-    .withReuse()
-    .start();
+  if (process.env.IMMICH_TEST_ASSET_PATH === undefined) {
+    IMMICH_TEST_ASSET_PATH = path.normalize(`${__dirname}/../assets/`);
+    process.env.IMMICH_TEST_ASSET_PATH = IMMICH_TEST_ASSET_PATH;
+  } else {
+    IMMICH_TEST_ASSET_PATH = process.env.IMMICH_TEST_ASSET_PATH;
+  }
 
 
-  process.env.DB_URL = pg.getConnectionUri();
+  const directoryExists = async (dirPath: string) =>
+    await fs.promises
+      .access(dirPath)
+      .then(() => true)
+      .catch(() => false);
 
 
-  const redis = await new GenericContainer('redis').withExposedPorts(6379).withReuse().start();
+  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`,
+    );
+  }
 
 
-  process.env.REDIS_PORT = String(redis.getMappedPort(6379));
-  process.env.REDIS_HOSTNAME = redis.getHost();
+  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('postgres')
+      .withExposedPorts(5432)
+      .withDatabase('immich')
+      .withUsername('postgres')
+      .withPassword('postgres')
+      .withReuse()
+      .start();
+
+    process.env.DB_URL = pg.getConnectionUri();
+  }
+
+  process.env.NODE_ENV = 'development';
+  process.env.TYPESENSE_ENABLED = 'false';
+  process.env.IMMICH_MACHINE_LEARNING_ENABLED = 'false';
+  process.env.IMMICH_TEST_ENV = 'true';
+  process.env.TZ = 'Z';
 };
 };

+ 3 - 7
server/test/e2e/shared-link.e2e-spec.ts

@@ -1,11 +1,11 @@
 import { AlbumResponseDto, LoginResponseDto, SharedLinkResponseDto } from '@app/domain';
 import { AlbumResponseDto, LoginResponseDto, SharedLinkResponseDto } from '@app/domain';
-import { AppModule, PartnerController } from '@app/immich';
+import { PartnerController } from '@app/immich';
 import { SharedLinkType } from '@app/infra/entities';
 import { SharedLinkType } from '@app/infra/entities';
 import { INestApplication } from '@nestjs/common';
 import { INestApplication } from '@nestjs/common';
-import { Test, TestingModule } from '@nestjs/testing';
 import { api } from '@test/api';
 import { api } from '@test/api';
 import { db } from '@test/db';
 import { db } from '@test/db';
 import { errorStub, uuidStub } from '@test/fixtures';
 import { errorStub, uuidStub } from '@test/fixtures';
+import { createTestApp } from '@test/test-utils';
 import request from 'supertest';
 import request from 'supertest';
 
 
 const user1Dto = {
 const user1Dto = {
@@ -25,11 +25,7 @@ describe(`${PartnerController.name} (e2e)`, () => {
   let sharedLink: SharedLinkResponseDto;
   let sharedLink: SharedLinkResponseDto;
 
 
   beforeAll(async () => {
   beforeAll(async () => {
-    const moduleFixture: TestingModule = await Test.createTestingModule({
-      imports: [AppModule],
-    }).compile();
-
-    app = await moduleFixture.createNestApplication().init();
+    app = await createTestApp();
     server = app.getHttpServer();
     server = app.getHttpServer();
   });
   });
 
 

+ 3 - 6
server/test/e2e/user.e2e-spec.ts

@@ -2,10 +2,10 @@ import { LoginResponseDto, UserResponseDto, UserService } from '@app/domain';
 import { AppModule, UserController } from '@app/immich';
 import { AppModule, UserController } from '@app/immich';
 import { UserEntity } from '@app/infra/entities';
 import { UserEntity } from '@app/infra/entities';
 import { INestApplication } from '@nestjs/common';
 import { INestApplication } from '@nestjs/common';
-import { Test, TestingModule } from '@nestjs/testing';
 import { api } from '@test/api';
 import { api } from '@test/api';
 import { db } from '@test/db';
 import { db } from '@test/db';
 import { errorStub, userSignupStub, userStub } from '@test/fixtures';
 import { errorStub, userSignupStub, userStub } from '@test/fixtures';
+import { createTestApp } from '@test/test-utils';
 import request from 'supertest';
 import request from 'supertest';
 import { Repository } from 'typeorm';
 import { Repository } from 'typeorm';
 
 
@@ -18,12 +18,9 @@ describe(`${UserController.name}`, () => {
   let userRepository: Repository<UserEntity>;
   let userRepository: Repository<UserEntity>;
 
 
   beforeAll(async () => {
   beforeAll(async () => {
-    const moduleFixture: TestingModule = await Test.createTestingModule({
-      imports: [AppModule],
-    }).compile();
+    app = await createTestApp();
+    userRepository = app.select(AppModule).get('UserEntityRepository');
 
 
-    app = await moduleFixture.createNestApplication().init();
-    userRepository = moduleFixture.get('UserEntityRepository');
     server = app.getHttpServer();
     server = app.getHttpServer();
   });
   });
 
 

+ 59 - 148
server/test/test-utils.ts

@@ -1,22 +1,15 @@
-import {
-  AdminSignupResponseDto,
-  AlbumResponseDto,
-  AuthDeviceResponseDto,
-  AuthUserDto,
-  CreateUserDto,
-  LibraryResponseDto,
-  LoginCredentialDto,
-  LoginResponseDto,
-  SharedLinkCreateDto,
-  SharedLinkResponseDto,
-  UpdateUserDto,
-  UserResponseDto,
-} from '@app/domain';
-import { CreateAlbumDto } from '@app/domain/album/dto/album-create.dto';
 import { dataSource } from '@app/infra';
 import { dataSource } from '@app/infra';
-import { UserEntity } from '@app/infra/entities';
-import request from 'supertest';
-import { adminSignupStub, loginResponseStub, loginStub, signupResponseStub } from './fixtures';
+
+import { IJobRepository, JobItem, JobItemHandler, QueueName } from '@app/domain';
+import { AppModule } from '@app/immich';
+import { INestApplication, Logger } from '@nestjs/common';
+import { Test, TestingModule } from '@nestjs/testing';
+import * as fs from 'fs';
+import path from 'path';
+import { AppService } from '../src/microservices/app.service';
+
+export const IMMICH_TEST_ASSET_PATH = process.env.IMMICH_TEST_ASSET_PATH;
+export const IMMICH_TEST_ASSET_TEMP_PATH = path.normalize(`${IMMICH_TEST_ASSET_PATH}/temp/`);
 
 
 export const db = {
 export const db = {
   reset: async () => {
   reset: async () => {
@@ -41,135 +34,53 @@ export const db = {
   },
   },
 };
 };
 
 
-export function getAuthUser(): AuthUserDto {
-  return {
-    id: '3108ac14-8afb-4b7e-87fd-39ebb6b79750',
-    email: 'test@email.com',
-    isAdmin: false,
-  };
+let _handler: JobItemHandler = () => Promise.resolve();
+
+export async function createTestApp(runJobs = false, log = false): Promise<INestApplication> {
+  const moduleBuilder = Test.createTestingModule({
+    imports: [AppModule],
+    providers: [AppService],
+  })
+    .overrideProvider(IJobRepository)
+    .useValue({
+      addHandler: (_queueName: QueueName, _concurrency: number, handler: JobItemHandler) => (_handler = handler),
+      queue: (item: JobItem) => runJobs && _handler(item),
+      resume: jest.fn(),
+      empty: jest.fn(),
+      setConcurrency: jest.fn(),
+      getQueueStatus: jest.fn(),
+      getJobCounts: jest.fn(),
+      pause: jest.fn(),
+    } as IJobRepository);
+
+  const moduleFixture: TestingModule = await moduleBuilder.compile();
+
+  const app = moduleFixture.createNestApplication();
+  if (log) {
+    app.useLogger(new Logger());
+  } else {
+    app.useLogger(false);
+  }
+  await app.init();
+  const appService = app.get(AppService);
+  await appService.init();
+
+  return app;
 }
 }
 
 
-export const api = {
-  adminSignUp: async (server: any) => {
-    const { status, body } = await request(server).post('/auth/admin-sign-up').send(adminSignupStub);
-
-    expect(status).toBe(201);
-    expect(body).toEqual(signupResponseStub);
-
-    return body as AdminSignupResponseDto;
-  },
-  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;
-  },
-  userCreate: async (server: any, accessToken: string, user: Partial<UserEntity>) => {
-    const { status, body } = await request(server)
-      .post('/user')
-      .set('Authorization', `Bearer ${accessToken}`)
-      .send(user);
-
-    expect(status).toBe(201);
-
-    return body as UserResponseDto;
-  },
-  login: async (server: any, dto: LoginCredentialDto) => {
-    const { status, body } = await request(server).post('/auth/login').send(dto);
-
-    expect(status).toEqual(201);
-    expect(body).toMatchObject({ accessToken: expect.any(String) });
-
-    return body as LoginResponseDto;
-  },
-  getAuthDevices: async (server: any, accessToken: string) => {
-    const { status, body } = await request(server).get('/auth/devices').set('Authorization', `Bearer ${accessToken}`);
-
-    expect(body).toEqual(expect.any(Array));
-    expect(status).toBe(200);
-
-    return body as AuthDeviceResponseDto[];
-  },
-  validateToken: async (server: any, accessToken: string) => {
-    const response = await request(server).post('/auth/validateToken').set('Authorization', `Bearer ${accessToken}`);
-    expect(response.body).toEqual({ authStatus: true });
-    expect(response.status).toBe(200);
-  },
-  albumApi: {
-    create: async (server: any, accessToken: string, dto: CreateAlbumDto) => {
-      const res = await request(server).post('/album').set('Authorization', `Bearer ${accessToken}`).send(dto);
-      expect(res.status).toEqual(201);
-      return res.body as AlbumResponseDto;
-    },
-  },
-  libraryApi: {
-    getAll: async (server: any, accessToken: string) => {
-      const res = await request(server).get('/library').set('Authorization', `Bearer ${accessToken}`);
-      expect(res.status).toEqual(200);
-      expect(Array.isArray(res.body)).toBe(true);
-      return res.body as LibraryResponseDto[];
-    },
-  },
-  sharedLinkApi: {
-    create: async (server: any, accessToken: string, dto: SharedLinkCreateDto) => {
-      const { status, body } = await request(server)
-        .post('/shared-link')
-        .set('Authorization', `Bearer ${accessToken}`)
-        .send(dto);
-      expect(status).toBe(201);
-      return body as SharedLinkResponseDto;
-    },
-  },
-  userApi: {
-    create: async (server: any, accessToken: string, dto: CreateUserDto) => {
-      const { status, body } = await request(server)
-        .post('/user')
-        .set('Authorization', `Bearer ${accessToken}`)
-        .send(dto);
-
-      expect(status).toBe(201);
-      expect(body).toMatchObject({
-        id: expect.any(String),
-        createdAt: expect.any(String),
-        updatedAt: expect.any(String),
-        email: dto.email,
-      });
-
-      return body as UserResponseDto;
-    },
-    get: async (server: any, accessToken: string, id: string) => {
-      const { status, body } = await request(server)
-        .get(`/user/info/${id}`)
-        .set('Authorization', `Bearer ${accessToken}`);
-
-      expect(status).toBe(200);
-      expect(body).toMatchObject({ id });
-
-      return body as UserResponseDto;
-    },
-    update: async (server: any, accessToken: string, dto: UpdateUserDto) => {
-      const { status, body } = await request(server)
-        .put('/user')
-        .set('Authorization', `Bearer ${accessToken}`)
-        .send(dto);
-
-      expect(status).toBe(200);
-      expect(body).toMatchObject({ id: dto.id });
-
-      return body as UserResponseDto;
-    },
-    delete: async (server: any, accessToken: string, id: string) => {
-      const { status, body } = await request(server)
-        .delete(`/user/${id}`)
-        .set('Authorization', `Bearer ${accessToken}`);
-
-      expect(status).toBe(200);
-      expect(body).toMatchObject({ id, deletedAt: expect.any(String) });
-
-      return body as UserResponseDto;
-    },
-  },
-} as const;
+export const runAllTests: boolean = process.env.IMMICH_RUN_ALL_TESTS === 'true';
+
+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);
+}

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است