From 8d5bf933601a3f2787a78c40e4c11862b96566e0 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 6 Oct 2023 17:32:28 -0400 Subject: [PATCH] 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 --- .github/workflows/test.yml | 11 +- .gitmodules | 3 + Makefile | 2 +- docker/.env.test | 16 - docker/docker-compose.test.yml | 32 +- docs/docs/developer/testing.md | 17 + server/package-lock.json | 58 +- server/package.json | 5 +- server/src/domain/job/job.repository.ts | 3 +- .../domain/library/library.service.spec.ts | 2 +- server/src/domain/library/library.service.ts | 4 +- .../src/immich/api-v1/asset/asset.service.ts | 2 +- server/src/infra/infra.config.ts | 4 + server/src/infra/infra.module.ts | 24 +- server/test/api/asset-api.ts | 7 +- server/test/api/library-api.ts | 39 +- server/test/api/user-api.ts | 3 + server/test/e2e/album.e2e-spec.ts | 9 +- server/test/e2e/asset.e2e-spec.ts | 31 +- server/test/e2e/auth.e2e-spec.ts | 10 +- server/test/e2e/formats.e2e-spec.ts | 206 ++++ server/test/e2e/library.e2e-spec.ts | 957 +++++++++++++----- server/test/e2e/oauth.e2e-spec.ts | 10 +- server/test/e2e/partner.e2e-spec.ts | 10 +- server/test/e2e/person.e2e-spec.ts | 10 +- server/test/e2e/server-info.e2e-spec.ts | 16 +- server/test/e2e/setup.ts | 66 +- server/test/e2e/shared-link.e2e-spec.ts | 10 +- server/test/e2e/user.e2e-spec.ts | 9 +- server/test/test-utils.ts | 203 ++-- 30 files changed, 1245 insertions(+), 534 deletions(-) delete mode 100644 docker/.env.test create mode 100644 docs/docs/developer/testing.md create mode 100644 server/test/e2e/formats.e2e-spec.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 89c522ab61..48d5af118d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,20 +13,15 @@ jobs: e2e-tests: name: Run end-to-end test suites runs-on: ubuntu-latest - defaults: - run: - working-directory: ./server steps: - name: Checkout code uses: actions/checkout@v4 - - - name: Run npm install - run: npm ci + with: + submodules: "recursive" - 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: name: Run documentation checks diff --git a/.gitmodules b/.gitmodules index f4f1c1e56d..3c57a9ad57 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,6 @@ [submodule "mobile/.isar"] path = mobile/.isar url = https://github.com/isar/isar +[submodule "server/test/assets"] + path = server/test/assets + url = https://github.com/immich-app/test-assets diff --git a/Makefile b/Makefile index 7cdd1915b9..a8b86d75c6 100644 --- a/Makefile +++ b/Makefile @@ -20,7 +20,7 @@ pull-stage: docker-compose -f ./docker/docker-compose.staging.yml pull test-e2e: - docker-compose -f ./docker/docker-compose.test.yml --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: docker-compose -f ./docker/docker-compose.prod.yml up --build -V --remove-orphans diff --git a/docker/.env.test b/docker/.env.test deleted file mode 100644 index 68c179b42d..0000000000 --- a/docker/.env.test +++ /dev/null @@ -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 diff --git a/docker/docker-compose.test.yml b/docker/docker-compose.test.yml index 6690622915..57b0123340 100644 --- a/docker/docker-compose.test.yml +++ b/docker/docker-compose.test.yml @@ -1,5 +1,7 @@ version: "3.8" +# Compose file for dockerized end-to-end testing of the backend + services: immich-server-test: image: immich-server-test @@ -8,39 +10,31 @@ services: dockerfile: Dockerfile target: builder command: npm run test:e2e - expose: - - "3000" volumes: - ../server:/usr/src/app - /usr/src/app/node_modules - env_file: - - .env.test 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: - - immich-redis-test - immich-database-test networks: - 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: container_name: immich-database-test image: postgres:14-alpine@sha256:28407a9961e76f2d285dc6991e8e48893503cc3836a4755bbc2d40bcc272a441 - env_file: - - .env.test 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: - immich-test-network + logging: + driver: none networks: immich-test-network: diff --git a/docs/docs/developer/testing.md b/docs/docs/developer/testing.md new file mode 100644 index 0000000000..ae3f68ce99 --- /dev/null +++ b/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`. diff --git a/server/package-lock.json b/server/package-lock.json index fa5a232d53..bab48f199d 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -100,7 +100,8 @@ "ts-loader": "^9.4.4", "ts-node": "^10.9.1", "tsconfig-paths": "^4.2.0", - "typescript": "^5.2.2" + "typescript": "^5.2.2", + "utimes": "^5.2.1" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -6857,6 +6858,15 @@ "!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": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", @@ -13789,6 +13799,26 @@ "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": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz", @@ -19202,6 +19232,14 @@ "exiftool-vendored.pl": "12.67.0", "he": "^1.2.0", "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": { @@ -24286,6 +24324,24 @@ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "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": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz", diff --git a/server/package.json b/server/package.json index d2e78fabea..be179baad4 100644 --- a/server/package.json +++ b/server/package.json @@ -26,7 +26,7 @@ "test:watch": "jest --watch", "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", - "test:e2e": "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:migrations:create": "typeorm migration:create", "typeorm:migrations:generate": "typeorm migration:generate -d ./dist/infra/database.config.js", @@ -126,7 +126,8 @@ "ts-loader": "^9.4.4", "ts-node": "^10.9.1", "tsconfig-paths": "^4.2.0", - "typescript": "^5.2.2" + "typescript": "^5.2.2", + "utimes": "^5.2.1" }, "jest": { "clearMocks": true, diff --git a/server/src/domain/job/job.repository.ts b/server/src/domain/job/job.repository.ts index 12e865775f..be2ee7a901 100644 --- a/server/src/domain/job/job.repository.ts +++ b/server/src/domain/job/job.repository.ts @@ -107,11 +107,12 @@ export type JobItem = | { name: JobName.SEARCH_REMOVE_FACE; data: IAssetFaceJob }; export type JobHandler = (data: T) => boolean | Promise; +export type JobItemHandler = (item: JobItem) => Promise; export const IJobRepository = 'IJobRepository'; export interface IJobRepository { - addHandler(queueName: QueueName, concurrency: number, handler: (job: JobItem) => Promise): void; + addHandler(queueName: QueueName, concurrency: number, handler: JobItemHandler): void; setConcurrency(queueName: QueueName, concurrency: number): void; queue(item: JobItem): Promise; pause(name: QueueName): Promise; diff --git a/server/src/domain/library/library.service.spec.ts b/server/src/domain/library/library.service.spec.ts index 993b3499a3..dd137433a0 100644 --- a/server/src/domain/library/library.service.spec.ts +++ b/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 () => { assetMock.getWith.mockResolvedValue({ items: [assetStub.image1], hasNextPage: false }); assetMock.getById.mockResolvedValue(assetStub.image1); diff --git a/server/src/domain/library/library.service.ts b/server/src/domain/library/library.service.ts index 0afb4f4237..660aec60bd 100644 --- a/server/src/domain/library/library.service.ts +++ b/server/src/domain/library/library.service.ts @@ -363,6 +363,8 @@ export class LibraryService { return false; } + const normalizedExternalPath = path.normalize(user.externalPath); + this.logger.verbose(`Refreshing library: ${job.id}`); const crawledAssetPaths = ( await this.storageRepository.crawl({ @@ -373,7 +375,7 @@ export class LibraryService { .map(path.normalize) .filter((assetPath) => // 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}`); diff --git a/server/src/immich/api-v1/asset/asset.service.ts b/server/src/immich/api-v1/asset/asset.service.ts index cbb89fcb08..9f2c25196b 100644 --- a/server/src/immich/api-v1/asset/asset.service.ts +++ b/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); - throw new BadRequestException(`Error uploading file`, `${error}`); + throw error; } } diff --git a/server/src/infra/infra.config.ts b/server/src/infra/infra.config.ts index a3bcd10072..90477d8ca3 100644 --- a/server/src/infra/infra.config.ts +++ b/server/src/infra/infra.config.ts @@ -5,6 +5,10 @@ import { RedisOptions } from 'ioredis'; import { ConfigurationOptions } from 'typesense/lib/Typesense/Configuration'; function parseRedisConfig(): RedisOptions { + if (process.env.IMMICH_TEST_ENV == 'true') { + return {}; + } + const redisUrl = process.env.REDIS_URL; if (redisUrl && redisUrl.startsWith('ioredis://')) { try { diff --git a/server/src/infra/infra.module.ts b/server/src/infra/infra.module.ts index 48f9a89007..56d70cfb4e 100644 --- a/server/src/infra/infra.module.ts +++ b/server/src/infra/infra.module.ts @@ -80,16 +80,24 @@ const providers: Provider[] = [ { 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() @Module({ - imports: [ - ConfigModule.forRoot(immichAppConfig), - TypeOrmModule.forRoot(databaseConfig), - TypeOrmModule.forFeature(databaseEntities), - BullModule.forRoot(bullConfig), - BullModule.registerQueue(...bullQueues), - ], + imports, providers: [...providers], - exports: [...providers, BullModule], + exports: moduleExports, }) export class InfraModule {} diff --git a/server/test/api/asset-api.ts b/server/test/api/asset-api.ts index e433f6dc59..0c83b8abbd 100644 --- a/server/test/api/asset-api.ts +++ b/server/test/api/asset-api.ts @@ -7,13 +7,18 @@ import request from 'supertest'; type UploadDto = Partial & { content?: Buffer }; export const assetApi = { - get: async (server: any, accessToken: string, id: string) => { + get: async (server: any, accessToken: string, id: string): Promise => { const { body, status } = await request(server) .get(`/asset/assetById/${id}`) .set('Authorization', `Bearer ${accessToken}`); expect(status).toBe(200); 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 = {}) => { const { content, isFavorite = false, isArchived = false } = dto; const { body, status } = await request(server) diff --git a/server/test/api/library-api.ts b/server/test/api/library-api.ts index 4c5a08aa92..d70e7bd623 100644 --- a/server/test/api/library-api.ts +++ b/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'; export const libraryApi = { @@ -7,4 +7,41 @@ export const libraryApi = { 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(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 => { + const { body, status } = await request(server) + .get(`/library/${id}/statistics`) + .set('Authorization', `Bearer ${accessToken}`); + expect(status).toBe(200); + return body; + }, }; diff --git a/server/test/api/user-api.ts b/server/test/api/user-api.ts index 20acf50c38..5ed0838f75 100644 --- a/server/test/api/user-api.ts +++ b/server/test/api/user-api.ts @@ -36,6 +36,9 @@ export const userApi = { 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) => { const { status, body } = await request(server).delete(`/user/${id}`).set('Authorization', `Bearer ${accessToken}`); diff --git a/server/test/e2e/album.e2e-spec.ts b/server/test/e2e/album.e2e-spec.ts index 7f60d8124a..633a825a76 100644 --- a/server/test/e2e/album.e2e-spec.ts +++ b/server/test/e2e/album.e2e-spec.ts @@ -1,12 +1,12 @@ 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 { SharedLinkType } from '@app/infra/entities'; import { INestApplication } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; import { api } from '@test/api'; import { db } from '@test/db'; import { errorStub, uuidStub } from '@test/fixtures'; +import { createTestApp } from '@test/test-utils'; import request from 'supertest'; const user1SharedUser = 'user1SharedUser'; @@ -27,11 +27,8 @@ describe(`${AlbumController.name} (e2e)`, () => { let user2Albums: AlbumResponseDto[]; beforeAll(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModule], - }).compile(); + app = await createTestApp(); - app = await moduleFixture.createNestApplication().init(); server = app.getHttpServer(); }); diff --git a/server/test/e2e/asset.e2e-spec.ts b/server/test/e2e/asset.e2e-spec.ts index a247409a9c..fc057934a9 100644 --- a/server/test/e2e/asset.e2e-spec.ts +++ b/server/test/e2e/asset.e2e-spec.ts @@ -6,13 +6,12 @@ import { LoginResponseDto, TimeBucketSize, } from '@app/domain'; -import { AppModule, AssetController } from '@app/immich'; +import { AssetController } from '@app/immich'; import { AssetEntity, AssetType } from '@app/infra/entities'; import { INestApplication } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; import { api } from '@test/api'; -import { db } from '@test/db'; import { errorStub, uuidStub } from '@test/fixtures'; +import { createTestApp, db } from '@test/test-utils'; import { randomBytes } from 'crypto'; import request from 'supertest'; @@ -85,11 +84,8 @@ describe(`${AssetController.name} (e2e)`, () => { let asset4: AssetEntity; beforeAll(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModule], - }).compile(); + app = await createTestApp(); - app = await moduleFixture.createNestApplication().init(); server = app.getHttpServer(); assetRepository = app.get(IAssetRepository); }); @@ -200,6 +196,27 @@ describe(`${AssetController.name} (e2e)`, () => { expect(status).toBe(200); 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', () => { diff --git a/server/test/e2e/auth.e2e-spec.ts b/server/test/e2e/auth.e2e-spec.ts index bff6b976ed..4068634e74 100644 --- a/server/test/e2e/auth.e2e-spec.ts +++ b/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 { Test, TestingModule } from '@nestjs/testing'; import { api } from '@test/api'; import { db } from '@test/db'; import { @@ -13,6 +12,7 @@ import { signupResponseStub, uuidStub, } from '@test/fixtures'; +import { createTestApp } from '@test/test-utils'; import request from 'supertest'; const firstName = 'Immich'; @@ -26,11 +26,7 @@ describe(`${AuthController.name} (e2e)`, () => { let accessToken: string; beforeAll(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModule], - }).compile(); - - app = await moduleFixture.createNestApplication().init(); + app = await createTestApp(); server = app.getHttpServer(); }); diff --git a/server/test/e2e/formats.e2e-spec.ts b/server/test/e2e/formats.e2e-spec.ts new file mode 100644 index 0000000000..98e24ec9ac --- /dev/null +++ b/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), + }), + ]); + }); +}); diff --git a/server/test/e2e/library.e2e-spec.ts b/server/test/e2e/library.e2e-spec.ts index 9a047176e0..b7aa2a1091 100644 --- a/server/test/e2e/library.e2e-spec.ts +++ b/server/test/e2e/library.e2e-spec.ts @@ -1,37 +1,55 @@ -import { LoginResponseDto } from '@app/domain'; -import { AppModule, LibraryController } from '@app/immich'; -import { LibraryType } from '@app/infra/entities'; +import { LibraryResponseDto, LoginResponseDto } from '@app/domain'; +import { LibraryController } from '@app/immich'; +import { AssetType, LibraryType } from '@app/infra/entities'; import { INestApplication } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; +import { api } from '@test/api'; +import { + IMMICH_TEST_ASSET_PATH, + IMMICH_TEST_ASSET_TEMP_PATH, + createTestApp, + db, + restoreTempFolder, +} from '@test/test-utils'; +import * as fs from 'fs'; import request from 'supertest'; -import { errorStub, userStub, uuidStub } from '../fixtures'; -import { api, db } from '../test-utils'; +import { utimes } from 'utimes'; +import { errorStub, uuidStub } from '../fixtures'; describe(`${LibraryController.name} (e2e)`, () => { let app: INestApplication; let server: any; - let loginResponse: LoginResponseDto; - let accessToken: string; + let admin: LoginResponseDto; + + const user1Dto = { + email: 'user1@immich.app', + password: 'Password123', + firstName: 'User 1', + lastName: 'Test', + }; + + const user2Dto = { + email: 'user2@immich.app', + password: 'Password123', + firstName: 'User 2', + lastName: 'Test', + }; beforeAll(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModule], - }).compile(); - - app = await moduleFixture.createNestApplication().init(); + app = await createTestApp(true); server = app.getHttpServer(); }); beforeEach(async () => { await db.reset(); - await api.adminSignUp(server); - loginResponse = await api.adminLogin(server); - accessToken = loginResponse.accessToken; + restoreTempFolder(); + await api.authApi.adminSignUp(server); + admin = await api.authApi.adminLogin(server); }); afterAll(async () => { await db.disconnect(); await app.close(); + restoreTempFolder(); }); describe('GET /library', () => { @@ -42,22 +60,21 @@ describe(`${LibraryController.name} (e2e)`, () => { }); it('should start with a default upload library', async () => { - const { status, body } = await request(server).get('/library').set('Authorization', `Bearer ${accessToken}`); + const { status, body } = await request(server) + .get('/library') + .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(200); expect(body).toHaveLength(1); expect(body).toEqual([ - { - id: expect.any(String), - ownerId: loginResponse.userId, + expect.objectContaining({ + ownerId: admin.userId, type: LibraryType.UPLOAD, name: 'Default Library', - createdAt: expect.any(String), - updatedAt: expect.any(String), refreshedAt: null, assetCount: 0, importPaths: [], exclusionPatterns: [], - }, + }), ]); }); }); @@ -73,85 +90,63 @@ describe(`${LibraryController.name} (e2e)`, () => { it('with default settings', async () => { const { status, body } = await request(server) .post('/library') - .set('Authorization', `Bearer ${accessToken}`) + .set('Authorization', `Bearer ${admin.accessToken}`) .send({ type: LibraryType.EXTERNAL }); - expect(status).toBe(201); - expect(body).toEqual({ - id: expect.any(String), - ownerId: loginResponse.userId, - type: LibraryType.EXTERNAL, - name: 'New External Library', - createdAt: expect.any(String), - updatedAt: expect.any(String), - refreshedAt: null, - assetCount: 0, - importPaths: [], - exclusionPatterns: [], - }); + expect(status).toBe(201); + expect(body).toEqual( + expect.objectContaining({ + ownerId: admin.userId, + type: LibraryType.EXTERNAL, + name: 'New External Library', + refreshedAt: null, + assetCount: 0, + importPaths: [], + exclusionPatterns: [], + }), + ); }); it('with name', async () => { const { status, body } = await request(server) .post('/library') - .set('Authorization', `Bearer ${accessToken}`) + .set('Authorization', `Bearer ${admin.accessToken}`) .send({ type: LibraryType.EXTERNAL, name: 'My Awesome Library' }); - expect(status).toBe(201); - expect(body).toEqual({ - id: expect.any(String), - ownerId: loginResponse.userId, - type: LibraryType.EXTERNAL, - name: 'My Awesome Library', - createdAt: expect.any(String), - updatedAt: expect.any(String), - refreshedAt: null, - assetCount: 0, - importPaths: [], - exclusionPatterns: [], - }); + expect(status).toBe(201); + expect(body).toEqual( + expect.objectContaining({ + name: 'My Awesome Library', + }), + ); }); it('with import paths', async () => { const { status, body } = await request(server) .post('/library') - .set('Authorization', `Bearer ${accessToken}`) + .set('Authorization', `Bearer ${admin.accessToken}`) .send({ type: LibraryType.EXTERNAL, importPaths: ['/path/to/import'] }); - expect(status).toBe(201); - expect(body).toEqual({ - id: expect.any(String), - ownerId: loginResponse.userId, - type: LibraryType.EXTERNAL, - name: 'New External Library', - createdAt: expect.any(String), - updatedAt: expect.any(String), - refreshedAt: null, - assetCount: 0, - importPaths: ['/path/to/import'], - exclusionPatterns: [], - }); + expect(status).toBe(201); + expect(body).toEqual( + expect.objectContaining({ + importPaths: ['/path/to/import'], + }), + ); }); it('with exclusion patterns', async () => { const { status, body } = await request(server) .post('/library') - .set('Authorization', `Bearer ${accessToken}`) + .set('Authorization', `Bearer ${admin.accessToken}`) .send({ type: LibraryType.EXTERNAL, exclusionPatterns: ['**/Raw/**'] }); - expect(status).toBe(201); - expect(body).toEqual({ - id: expect.any(String), - ownerId: loginResponse.userId, - type: LibraryType.EXTERNAL, - name: 'New External Library', - createdAt: expect.any(String), - updatedAt: expect.any(String), - refreshedAt: null, - assetCount: 0, - importPaths: [], - exclusionPatterns: ['**/Raw/**'], - }); + expect(status).toBe(201); + expect(body).toEqual( + expect.objectContaining({ + exclusionPatterns: ['**/Raw/**'], + }), + ); }); }); @@ -159,92 +154,79 @@ describe(`${LibraryController.name} (e2e)`, () => { it('with default settings', async () => { const { status, body } = await request(server) .post('/library') - .set('Authorization', `Bearer ${accessToken}`) + .set('Authorization', `Bearer ${admin.accessToken}`) .send({ type: LibraryType.UPLOAD }); - expect(status).toBe(201); - expect(body).toEqual({ - id: expect.any(String), - ownerId: loginResponse.userId, - type: LibraryType.UPLOAD, - name: 'New Upload Library', - createdAt: expect.any(String), - updatedAt: expect.any(String), - refreshedAt: null, - assetCount: 0, - importPaths: [], - exclusionPatterns: [], - }); + expect(status).toBe(201); + expect(body).toEqual( + expect.objectContaining({ + ownerId: admin.userId, + type: LibraryType.UPLOAD, + name: 'New Upload Library', + refreshedAt: null, + assetCount: 0, + importPaths: [], + exclusionPatterns: [], + }), + ); }); it('with name', async () => { const { status, body } = await request(server) .post('/library') - .set('Authorization', `Bearer ${accessToken}`) + .set('Authorization', `Bearer ${admin.accessToken}`) .send({ type: LibraryType.UPLOAD, name: 'My Awesome Library' }); - expect(status).toBe(201); - expect(body).toEqual({ - id: expect.any(String), - ownerId: loginResponse.userId, - type: LibraryType.UPLOAD, - name: 'My Awesome Library', - createdAt: expect.any(String), - updatedAt: expect.any(String), - refreshedAt: null, - assetCount: 0, - importPaths: [], - exclusionPatterns: [], - }); + expect(status).toBe(201); + expect(body).toEqual( + expect.objectContaining({ + name: 'My Awesome Library', + }), + ); }); it('with import paths should fail', async () => { const { status, body } = await request(server) .post('/library') - .set('Authorization', `Bearer ${accessToken}`) + .set('Authorization', `Bearer ${admin.accessToken}`) .send({ type: LibraryType.UPLOAD, importPaths: ['/path/to/import'] }); - expect(status).toBe(400); + expect(status).toBe(400); expect(body).toEqual(errorStub.badRequest('Upload libraries cannot have import paths')); }); it('with exclusion patterns should fail', async () => { const { status, body } = await request(server) .post('/library') - .set('Authorization', `Bearer ${accessToken}`) + .set('Authorization', `Bearer ${admin.accessToken}`) .send({ type: LibraryType.UPLOAD, exclusionPatterns: ['**/Raw/**'] }); - expect(status).toBe(400); + expect(status).toBe(400); expect(body).toEqual(errorStub.badRequest('Upload libraries cannot have exclusion patterns')); }); }); it('should allow a user to create a library', async () => { - await api.userCreate(server, accessToken, userStub.user1); - - const loginResponse = await api.login(server, { - email: userStub.user1.email, - password: userStub.user1.password ?? '', - }); + await api.userApi.create(server, admin.accessToken, user1Dto); + const user1 = await api.authApi.login(server, { email: user1Dto.email, password: user1Dto.password }); const { status, body } = await request(server) .post('/library') - .set('Authorization', `Bearer ${loginResponse.accessToken}`) + .set('Authorization', `Bearer ${user1.accessToken}`) .send({ type: LibraryType.EXTERNAL }); expect(status).toBe(201); - expect(body).toEqual({ - id: expect.any(String), - ownerId: loginResponse.userId, - type: LibraryType.EXTERNAL, - name: 'New External Library', - createdAt: expect.any(String), - updatedAt: expect.any(String), - refreshedAt: null, - assetCount: 0, - importPaths: [], - exclusionPatterns: [], - }); + expect(body).toEqual( + expect.objectContaining({ + ownerId: user1.userId, + type: LibraryType.EXTERNAL, + name: 'New External Library', + refreshedAt: null, + assetCount: 0, + importPaths: [], + exclusionPatterns: [], + }), + ); }); }); @@ -256,94 +238,83 @@ describe(`${LibraryController.name} (e2e)`, () => { }); describe('external library', () => { - let libraryId: string; + let library: LibraryResponseDto; beforeEach(async () => { // Create an external library with default settings - const { status, body } = await request(server) - .post('/library') - .set('Authorization', `Bearer ${accessToken}`) - .send({ type: LibraryType.EXTERNAL }); - - expect(status).toBe(201); - - libraryId = body.id; + library = await api.libraryApi.create(server, admin.accessToken, { type: LibraryType.EXTERNAL }); }); it('should change the library name', async () => { const { status, body } = await request(server) - .put(`/library/${libraryId}`) - .set('Authorization', `Bearer ${accessToken}`) + .put(`/library/${library.id}`) + .set('Authorization', `Bearer ${admin.accessToken}`) .send({ name: 'New Library Name' }); + expect(status).toBe(200); - expect(body).toEqual({ - id: expect.any(String), - ownerId: loginResponse.userId, - type: LibraryType.EXTERNAL, - name: 'New Library Name', - createdAt: expect.any(String), - updatedAt: expect.any(String), - refreshedAt: null, - assetCount: 0, - importPaths: [], - exclusionPatterns: [], - }); + expect(body).toEqual( + expect.objectContaining({ + name: 'New Library Name', + }), + ); }); it('should not set an empty name', async () => { const { status, body } = await request(server) - .put(`/library/${libraryId}`) - .set('Authorization', `Bearer ${accessToken}`) + .put(`/library/${library.id}`) + .set('Authorization', `Bearer ${admin.accessToken}`) .send({ name: '' }); + expect(status).toBe(400); expect(body).toEqual(errorStub.badRequest(['name should not be empty'])); }); it('should change the import paths', async () => { const { status, body } = await request(server) - .put(`/library/${libraryId}`) - .set('Authorization', `Bearer ${accessToken}`) + .put(`/library/${library.id}`) + .set('Authorization', `Bearer ${admin.accessToken}`) .send({ importPaths: ['/path/to/import'] }); + expect(status).toBe(200); - expect(body).toEqual({ - id: expect.any(String), - ownerId: loginResponse.userId, - type: LibraryType.EXTERNAL, - name: 'New External Library', - createdAt: expect.any(String), - updatedAt: expect.any(String), - refreshedAt: null, - assetCount: 0, - importPaths: ['/path/to/import'], - exclusionPatterns: [], - }); + expect(body).toEqual( + expect.objectContaining({ + importPaths: ['/path/to/import'], + }), + ); }); it('should not allow an empty import path', async () => { const { status, body } = await request(server) - .put(`/library/${libraryId}`) - .set('Authorization', `Bearer ${accessToken}`) + .put(`/library/${library.id}`) + .set('Authorization', `Bearer ${admin.accessToken}`) .send({ importPaths: [''] }); + expect(status).toBe(400); expect(body).toEqual(errorStub.badRequest(['each value in importPaths should not be empty'])); }); it('should change the exclusion pattern', async () => { const { status, body } = await request(server) - .put(`/library/${libraryId}`) - .set('Authorization', `Bearer ${accessToken}`) - .send({ exclusionPatterns: [''] }); - expect(status).toBe(400); - expect(body).toEqual(errorStub.badRequest(['each value in exclusionPatterns should not be empty'])); + .put(`/library/${library.id}`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ exclusionPatterns: ['**/Raw/**'] }); + + expect(status).toBe(200); + expect(body).toEqual( + expect.objectContaining({ + exclusionPatterns: ['**/Raw/**'], + }), + ); }); it('should not allow an empty exclusion pattern', async () => { const { status, body } = await request(server) - .put(`/library/${libraryId}`) - .set('Authorization', `Bearer ${accessToken}`) - .send({ importPaths: [''] }); + .put(`/library/${library.id}`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ exclusionPatterns: [''] }); + expect(status).toBe(400); - expect(body).toEqual(errorStub.badRequest(['each value in importPaths should not be empty'])); + expect(body).toEqual(errorStub.badRequest(['each value in exclusionPatterns should not be empty'])); }); }); }); @@ -351,60 +322,44 @@ describe(`${LibraryController.name} (e2e)`, () => { describe('GET /library/:id', () => { it('should require authentication', async () => { const { status, body } = await request(server).get(`/library/${uuidStub.notFound}`); + expect(status).toBe(401); expect(body).toEqual(errorStub.unauthorized); }); it('should get library by id', async () => { - let libraryId: string; - { - const { status, body } = await request(server) - .post('/library') - .set('Authorization', `Bearer ${accessToken}`) - .send({ type: LibraryType.EXTERNAL }); - expect(status).toBe(201); - libraryId = body.id; - } + const library = await api.libraryApi.create(server, admin.accessToken, { type: LibraryType.EXTERNAL }); + const { status, body } = await request(server) - .get(`/library/${libraryId}`) - .set('Authorization', `Bearer ${accessToken}`); + .get(`/library/${library.id}`) + .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(200); - expect(body).toEqual({ - id: expect.any(String), - ownerId: loginResponse.userId, - type: LibraryType.EXTERNAL, - name: 'New External Library', - createdAt: expect.any(String), - updatedAt: expect.any(String), - refreshedAt: null, - assetCount: 0, - importPaths: [], - exclusionPatterns: [], - }); + expect(body).toEqual( + expect.objectContaining({ + ownerId: admin.userId, + type: LibraryType.EXTERNAL, + name: 'New External Library', + refreshedAt: null, + assetCount: 0, + importPaths: [], + exclusionPatterns: [], + }), + ); }); it("should not allow getting another user's library", async () => { - await api.userCreate(server, accessToken, userStub.user1); + await api.userApi.create(server, admin.accessToken, user1Dto); + const user1 = await api.authApi.login(server, { email: user1Dto.email, password: user1Dto.password }); - const loginResponse = await api.login(server, { - email: userStub.user1.email, - password: userStub.user1.password ?? '', - }); + await api.userApi.create(server, admin.accessToken, user2Dto); + const user2 = await api.authApi.login(server, { email: user2Dto.email, password: user2Dto.password }); - let libraryId: string; - { - const { status, body } = await request(server) - .post('/library') - .set('Authorization', `Bearer ${accessToken}`) - .send({ type: LibraryType.EXTERNAL }); - expect(status).toBe(201); - libraryId = body.id; - } + const library = await api.libraryApi.create(server, user1.accessToken, { type: LibraryType.EXTERNAL }); const { status, body } = await request(server) - .get(`/library/${libraryId}`) - .set('Authorization', `Bearer ${loginResponse.accessToken}`); + .get(`/library/${library.id}`) + .set('Authorization', `Bearer ${user2.accessToken}`); expect(status).toBe(400); expect(body).toEqual(errorStub.badRequest('Not found or no library.read access')); @@ -414,25 +369,79 @@ describe(`${LibraryController.name} (e2e)`, () => { describe('DELETE /library/:id', () => { it('should require authentication', async () => { const { status, body } = await request(server).delete(`/library/${uuidStub.notFound}`); + expect(status).toBe(401); expect(body).toEqual(errorStub.unauthorized); }); it('should not delete the last upload library', async () => { - const [defaultLibrary] = await api.libraryApi.getAll(server, accessToken); + const [defaultLibrary] = await api.libraryApi.getAll(server, admin.accessToken); expect(defaultLibrary).toBeDefined(); const { status, body } = await request(server) .delete(`/library/${defaultLibrary.id}`) - .set('Authorization', `Bearer ${accessToken}`); + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(400); expect(body).toEqual(errorStub.noDeleteUploadLibrary); }); + + it('should delete an empty library', async () => { + const library = await api.libraryApi.create(server, admin.accessToken, { type: LibraryType.EXTERNAL }); + + const { status, body } = await request(server) + .delete(`/library/${library.id}`) + .set('Authorization', `Bearer ${admin.accessToken}`); + + expect(status).toBe(200); + expect(body).toEqual({}); + + const libraries = await api.libraryApi.getAll(server, admin.accessToken); + expect(libraries).toHaveLength(1); + expect(libraries).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: library.id, + }), + ]), + ); + }); + + it('should delete an extnernal library with assets', async () => { + const library = await api.libraryApi.create(server, admin.accessToken, { + type: LibraryType.EXTERNAL, + importPaths: [`${IMMICH_TEST_ASSET_PATH}/albums/nature`], + }); + await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, '/'); + + await api.libraryApi.scanLibrary(server, admin.accessToken, library.id); + + const assets = await api.assetApi.getAllAssets(server, admin.accessToken); + expect(assets.length).toBeGreaterThan(2); + + const { status, body } = await request(server) + .delete(`/library/${library.id}`) + .set('Authorization', `Bearer ${admin.accessToken}`); + + expect(status).toBe(200); + expect(body).toEqual({}); + + const libraries = await api.libraryApi.getAll(server, admin.accessToken); + expect(libraries).toHaveLength(1); + expect(libraries).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: library.id, + }), + ]), + ); + }); }); describe('GET /library/:id/statistics', () => { it('should require authentication', async () => { const { status, body } = await request(server).get(`/library/${uuidStub.notFound}/statistics`); + expect(status).toBe(401); expect(body).toEqual(errorStub.unauthorized); }); @@ -441,43 +450,440 @@ describe(`${LibraryController.name} (e2e)`, () => { describe('POST /library/:id/scan', () => { it('should require authentication', async () => { const { status, body } = await request(server).post(`/library/${uuidStub.notFound}/scan`).send({}); + expect(status).toBe(401); expect(body).toEqual(errorStub.unauthorized); }); - it('should scan external library', async () => { - let libraryId: string; - { - const { status, body } = await request(server) - .post('/library') - .set('Authorization', `Bearer ${accessToken}`) - .send({ type: LibraryType.EXTERNAL }); - expect(status).toBe(201); - libraryId = body.id; - } + it('should scan external library with import paths', async () => { + const library = await api.libraryApi.create(server, admin.accessToken, { + type: LibraryType.EXTERNAL, + importPaths: [`${IMMICH_TEST_ASSET_PATH}/albums/nature`], + }); + await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, '/'); - const { status, body } = await request(server) - .post(`/library/${libraryId}/scan`) - .set('Authorization', `Bearer ${accessToken}`); + await api.libraryApi.scanLibrary(server, admin.accessToken, library.id); - expect(status).toBe(201); - expect(body).toEqual({}); + const assets = await api.assetApi.getAllAssets(server, admin.accessToken); + + expect(assets).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: AssetType.IMAGE, + originalFileName: 'el_torcal_rocks', + libraryId: library.id, + resized: true, + thumbhash: expect.any(String), + exifInfo: expect.objectContaining({ + exifImageWidth: 512, + exifImageHeight: 341, + latitude: null, + longitude: null, + }), + }), + expect.objectContaining({ + type: AssetType.IMAGE, + originalFileName: 'silver_fir', + libraryId: library.id, + resized: true, + thumbhash: expect.any(String), + exifInfo: expect.objectContaining({ + exifImageWidth: 511, + exifImageHeight: 323, + latitude: null, + longitude: null, + }), + }), + ]), + ); + }); + + it('should scan external library with exclusion pattern', async () => { + await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, '/not/a/real/path'); + + const library = await api.libraryApi.create(server, admin.accessToken, { + type: LibraryType.EXTERNAL, + importPaths: [`${IMMICH_TEST_ASSET_PATH}/albums/nature`], + exclusionPatterns: ['**/el_corcal*'], + }); + + await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, '/'); + + await api.libraryApi.scanLibrary(server, admin.accessToken, library.id); + + const assets = await api.assetApi.getAllAssets(server, admin.accessToken); + + expect(assets).toEqual( + expect.arrayContaining([ + expect.not.objectContaining({ + // Excluded by exclusion pattern + originalFileName: 'el_torcal_rocks', + }), + expect.objectContaining({ + type: AssetType.IMAGE, + originalFileName: 'silver_fir', + libraryId: library.id, + resized: true, + exifInfo: expect.objectContaining({ + exifImageWidth: 511, + exifImageHeight: 323, + latitude: null, + longitude: null, + }), + }), + ]), + ); + }); + + it('should scan external library with import paths', async () => { + const library = await api.libraryApi.create(server, admin.accessToken, { + type: LibraryType.EXTERNAL, + importPaths: [`${IMMICH_TEST_ASSET_PATH}/albums/nature`], + }); + await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, '/'); + + await api.libraryApi.scanLibrary(server, admin.accessToken, library.id); + + const assets = await api.assetApi.getAllAssets(server, admin.accessToken); + + expect(assets).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: AssetType.IMAGE, + originalFileName: 'el_torcal_rocks', + libraryId: library.id, + resized: true, + exifInfo: expect.objectContaining({ + exifImageWidth: 512, + exifImageHeight: 341, + latitude: null, + longitude: null, + }), + }), + expect.objectContaining({ + type: AssetType.IMAGE, + originalFileName: 'silver_fir', + libraryId: library.id, + resized: true, + thumbhash: expect.any(String), + exifInfo: expect.objectContaining({ + exifImageWidth: 511, + exifImageHeight: 323, + latitude: null, + longitude: null, + }), + }), + ]), + ); + }); + + it('should offline missing files', async () => { + await fs.promises.cp(`${IMMICH_TEST_ASSET_PATH}/albums/nature`, `${IMMICH_TEST_ASSET_TEMP_PATH}/albums/nature`, { + recursive: true, + }); + + const library = await api.libraryApi.create(server, admin.accessToken, { + type: LibraryType.EXTERNAL, + importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`], + }); + await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, '/'); + + await api.libraryApi.scanLibrary(server, admin.accessToken, library.id); + + const onlineAssets = await api.assetApi.getAllAssets(server, admin.accessToken); + expect(onlineAssets.length).toBeGreaterThan(1); + + await restoreTempFolder(); + + await api.libraryApi.scanLibrary(server, admin.accessToken, library.id); + + const assets = await api.assetApi.getAllAssets(server, admin.accessToken); + + expect(assets).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + isOffline: true, + originalFileName: 'el_torcal_rocks', + }), + expect.objectContaining({ + isOffline: true, + originalFileName: 'tanners_ridge', + }), + ]), + ); + }); + + it('should offline files outside of changed external path', async () => { + const library = await api.libraryApi.create(server, admin.accessToken, { + type: LibraryType.EXTERNAL, + importPaths: [`${IMMICH_TEST_ASSET_PATH}/albums/nature`], + }); + await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, '/'); + await api.libraryApi.scanLibrary(server, admin.accessToken, library.id); + + await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, '/some/other/path'); + await api.libraryApi.scanLibrary(server, admin.accessToken, library.id); + + const assets = await api.assetApi.getAllAssets(server, admin.accessToken); + + expect(assets).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + isOffline: true, + originalFileName: 'el_torcal_rocks', + }), + expect.objectContaining({ + isOffline: true, + originalFileName: 'tanners_ridge', + }), + ]), + ); + }); + + it('should scan new files', async () => { + const library = await api.libraryApi.create(server, admin.accessToken, { + type: LibraryType.EXTERNAL, + importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`], + }); + await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, '/'); + + await fs.promises.cp( + `${IMMICH_TEST_ASSET_PATH}/albums/nature/silver_fir.jpg`, + `${IMMICH_TEST_ASSET_TEMP_PATH}/silver_fir.jpg`, + ); + + await api.libraryApi.scanLibrary(server, admin.accessToken, library.id); + + await fs.promises.cp( + `${IMMICH_TEST_ASSET_PATH}/albums/nature/el_torcal_rocks.jpg`, + `${IMMICH_TEST_ASSET_TEMP_PATH}/el_torcal_rocks.jpg`, + ); + + await api.libraryApi.scanLibrary(server, admin.accessToken, library.id); + + const assets = await api.assetApi.getAllAssets(server, admin.accessToken); + + expect(assets).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + originalFileName: 'el_torcal_rocks', + }), + expect.objectContaining({ + originalFileName: 'silver_fir', + }), + ]), + ); + }); + + describe('with refreshModifiedFiles=true', () => { + it('should reimport modified files', async () => { + const library = await api.libraryApi.create(server, admin.accessToken, { + type: LibraryType.EXTERNAL, + importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`], + }); + await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, '/'); + + await fs.promises.cp( + `${IMMICH_TEST_ASSET_PATH}/albums/nature/el_torcal_rocks.jpg`, + `${IMMICH_TEST_ASSET_TEMP_PATH}/el_torcal_rocks.jpg`, + ); + + await utimes(`${IMMICH_TEST_ASSET_TEMP_PATH}/el_torcal_rocks.jpg`, 447775200000); + + await api.libraryApi.scanLibrary(server, admin.accessToken, library.id); + + await fs.promises.cp( + `${IMMICH_TEST_ASSET_PATH}/albums/nature/tanners_ridge.jpg`, + `${IMMICH_TEST_ASSET_TEMP_PATH}/el_torcal_rocks.jpg`, + ); + + await utimes(`${IMMICH_TEST_ASSET_TEMP_PATH}/el_torcal_rocks.jpg`, 447775200001); + + await api.libraryApi.scanLibrary(server, admin.accessToken, library.id, { refreshModifiedFiles: true }); + + const assets = await api.assetApi.getAllAssets(server, admin.accessToken); + expect(assets.length).toBe(1); + + expect(assets[0]).toEqual( + expect.objectContaining({ + originalFileName: 'el_torcal_rocks', + exifInfo: expect.objectContaining({ + dateTimeOriginal: '2023-09-25T08:33:30.880Z', + exifImageHeight: 534, + exifImageWidth: 800, + exposureTime: '1/15', + fNumber: 22, + fileSizeInByte: 114225, + focalLength: 35, + iso: 1000, + make: 'NIKON CORPORATION', + model: 'NIKON D750', + }), + }), + ); + }); + + it('should not reimport unmodified files', async () => { + const library = await api.libraryApi.create(server, admin.accessToken, { + type: LibraryType.EXTERNAL, + importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`], + }); + await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, '/'); + + await fs.promises.cp( + `${IMMICH_TEST_ASSET_PATH}/albums/nature/el_torcal_rocks.jpg`, + `${IMMICH_TEST_ASSET_TEMP_PATH}/el_torcal_rocks.jpg`, + ); + + await utimes(`${IMMICH_TEST_ASSET_TEMP_PATH}/el_torcal_rocks.jpg`, 447775200000); + + await api.libraryApi.scanLibrary(server, admin.accessToken, library.id); + + await fs.promises.cp( + `${IMMICH_TEST_ASSET_PATH}/albums/nature/tanners_ridge.jpg`, + `${IMMICH_TEST_ASSET_TEMP_PATH}/el_torcal_rocks.jpg`, + ); + + await utimes(`${IMMICH_TEST_ASSET_TEMP_PATH}/el_torcal_rocks.jpg`, 447775200000); + + await api.libraryApi.scanLibrary(server, admin.accessToken, library.id, { refreshModifiedFiles: true }); + + const assets = await api.assetApi.getAllAssets(server, admin.accessToken); + expect(assets.length).toBe(1); + + expect(assets[0]).toEqual( + expect.objectContaining({ + originalFileName: 'el_torcal_rocks', + exifInfo: expect.objectContaining({ + dateTimeOriginal: '2012-08-05T11:39:59.000Z', + }), + }), + ); + }); + }); + + describe('with refreshAllFiles=true', () => { + it('should reimport all files', async () => { + const library = await api.libraryApi.create(server, admin.accessToken, { + type: LibraryType.EXTERNAL, + importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`], + }); + await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, '/'); + + await fs.promises.cp( + `${IMMICH_TEST_ASSET_PATH}/albums/nature/el_torcal_rocks.jpg`, + `${IMMICH_TEST_ASSET_TEMP_PATH}/el_torcal_rocks.jpg`, + ); + + await utimes(`${IMMICH_TEST_ASSET_TEMP_PATH}/el_torcal_rocks.jpg`, 447775200000); + + await api.libraryApi.scanLibrary(server, admin.accessToken, library.id); + + await fs.promises.cp( + `${IMMICH_TEST_ASSET_PATH}/albums/nature/tanners_ridge.jpg`, + `${IMMICH_TEST_ASSET_TEMP_PATH}/el_torcal_rocks.jpg`, + ); + + await utimes(`${IMMICH_TEST_ASSET_TEMP_PATH}/el_torcal_rocks.jpg`, 447775200000); + + await api.libraryApi.scanLibrary(server, admin.accessToken, library.id, { refreshAllFiles: true }); + + const assets = await api.assetApi.getAllAssets(server, admin.accessToken); + expect(assets.length).toBe(1); + + expect(assets[0]).toEqual( + expect.objectContaining({ + originalFileName: 'el_torcal_rocks', + exifInfo: expect.objectContaining({ + exifImageHeight: 534, + exifImageWidth: 800, + exposureTime: '1/15', + fNumber: 22, + fileSizeInByte: 114225, + focalLength: 35, + iso: 1000, + make: 'NIKON CORPORATION', + model: 'NIKON D750', + }), + }), + ); + }); + }); + + describe('External path', () => { + let library: LibraryResponseDto; + + beforeEach(async () => { + library = await api.libraryApi.create(server, admin.accessToken, { + type: LibraryType.EXTERNAL, + importPaths: [`${IMMICH_TEST_ASSET_PATH}/albums/nature`], + }); + }); + + it('should not scan assets for user without external path', async () => { + await api.libraryApi.scanLibrary(server, admin.accessToken, library.id); + const assets = await api.assetApi.getAllAssets(server, admin.accessToken); + + expect(assets).toEqual([]); + }); + + it("should not import assets outside of user's external path", async () => { + await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, '/not/a/real/path'); + await api.libraryApi.scanLibrary(server, admin.accessToken, library.id); + + const assets = await api.assetApi.getAllAssets(server, admin.accessToken); + expect(assets).toEqual([]); + }); + + it.each([`${IMMICH_TEST_ASSET_PATH}/albums/nature`, `${IMMICH_TEST_ASSET_PATH}/albums/nature/`])( + 'should scan external library with external path %s', + async (externalPath: string) => { + await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, externalPath); + + await api.libraryApi.scanLibrary(server, admin.accessToken, library.id); + + const assets = await api.assetApi.getAllAssets(server, admin.accessToken); + + expect(assets).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: AssetType.IMAGE, + originalFileName: 'el_torcal_rocks', + libraryId: library.id, + resized: true, + exifInfo: expect.objectContaining({ + exifImageWidth: 512, + exifImageHeight: 341, + latitude: null, + longitude: null, + }), + }), + expect.objectContaining({ + type: AssetType.IMAGE, + originalFileName: 'silver_fir', + libraryId: library.id, + resized: true, + exifInfo: expect.objectContaining({ + exifImageWidth: 511, + exifImageHeight: 323, + latitude: null, + longitude: null, + }), + }), + ]), + ); + }, + ); }); it('should not scan an upload library', async () => { - let libraryId: string; - { - const { status, body } = await request(server) - .post('/library') - .set('Authorization', `Bearer ${accessToken}`) - .send({ type: LibraryType.UPLOAD }); - expect(status).toBe(201); - libraryId = body.id; - } + const library = await api.libraryApi.create(server, admin.accessToken, { + type: LibraryType.UPLOAD, + }); const { status, body } = await request(server) - .post(`/library/${libraryId}/scan`) - .set('Authorization', `Bearer ${accessToken}`); + .post(`/library/${library.id}/scan`) + .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(400); expect(body).toEqual(errorStub.badRequest('Can only refresh external libraries')); @@ -487,8 +893,65 @@ describe(`${LibraryController.name} (e2e)`, () => { describe('POST /library/:id/removeOffline', () => { it('should require authentication', async () => { const { status, body } = await request(server).post(`/library/${uuidStub.notFound}/removeOffline`).send({}); + expect(status).toBe(401); expect(body).toEqual(errorStub.unauthorized); }); + + it('should remvove offline files', async () => { + await fs.promises.cp(`${IMMICH_TEST_ASSET_PATH}/albums/nature`, `${IMMICH_TEST_ASSET_TEMP_PATH}/albums/nature`, { + recursive: true, + }); + + const library = await api.libraryApi.create(server, admin.accessToken, { + type: LibraryType.EXTERNAL, + importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`], + }); + await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, '/'); + + await api.libraryApi.scanLibrary(server, admin.accessToken, library.id); + + const onlineAssets = await api.assetApi.getAllAssets(server, admin.accessToken); + expect(onlineAssets.length).toBeGreaterThan(1); + + await restoreTempFolder(); + + await api.libraryApi.scanLibrary(server, admin.accessToken, library.id); + + const { status } = await request(server) + .post(`/library/${library.id}/removeOffline`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send(); + expect(status).toBe(201); + + const assets = await api.assetApi.getAllAssets(server, admin.accessToken); + + expect(assets).toEqual([]); + }); + + it('should not remvove online files', async () => { + const library = await api.libraryApi.create(server, admin.accessToken, { + type: LibraryType.EXTERNAL, + importPaths: [`${IMMICH_TEST_ASSET_PATH}/albums/nature`], + }); + await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, '/'); + + await api.libraryApi.scanLibrary(server, admin.accessToken, library.id); + + const assetsBefore = await api.assetApi.getAllAssets(server, admin.accessToken); + expect(assetsBefore.length).toBeGreaterThan(1); + + await api.libraryApi.scanLibrary(server, admin.accessToken, library.id); + + const { status } = await request(server) + .post(`/library/${library.id}/removeOffline`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send(); + expect(status).toBe(201); + + const assetsAfter = await api.assetApi.getAllAssets(server, admin.accessToken); + + expect(assetsAfter).toEqual(assetsBefore); + }); }); }); diff --git a/server/test/e2e/oauth.e2e-spec.ts b/server/test/e2e/oauth.e2e-spec.ts index c2737f2a76..d0d2137c64 100644 --- a/server/test/e2e/oauth.e2e-spec.ts +++ b/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 { Test, TestingModule } from '@nestjs/testing'; import { api } from '@test/api'; import { db } from '@test/db'; import { errorStub } from '@test/fixtures'; +import { createTestApp } from '@test/test-utils'; import request from 'supertest'; describe(`${OAuthController.name} (e2e)`, () => { @@ -11,11 +11,7 @@ describe(`${OAuthController.name} (e2e)`, () => { let server: any; beforeAll(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModule], - }).compile(); - - app = await moduleFixture.createNestApplication().init(); + app = await createTestApp(); server = app.getHttpServer(); }); diff --git a/server/test/e2e/partner.e2e-spec.ts b/server/test/e2e/partner.e2e-spec.ts index 9283c11bcd..b0eb1d4ce7 100644 --- a/server/test/e2e/partner.e2e-spec.ts +++ b/server/test/e2e/partner.e2e-spec.ts @@ -1,10 +1,10 @@ import { IPartnerRepository, LoginResponseDto, PartnerDirection } from '@app/domain'; -import { AppModule, PartnerController } from '@app/immich'; +import { PartnerController } from '@app/immich'; import { INestApplication } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; import { api } from '@test/api'; import { db } from '@test/db'; import { errorStub } from '@test/fixtures'; +import { createTestApp } from '@test/test-utils'; import request from 'supertest'; const user1Dto = { @@ -31,11 +31,7 @@ describe(`${PartnerController.name} (e2e)`, () => { let user2: LoginResponseDto; beforeAll(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModule], - }).compile(); - - app = await moduleFixture.createNestApplication().init(); + app = await createTestApp(); server = app.getHttpServer(); repository = app.get(IPartnerRepository); }); diff --git a/server/test/e2e/person.e2e-spec.ts b/server/test/e2e/person.e2e-spec.ts index 49b94fd308..f9da56fa81 100644 --- a/server/test/e2e/person.e2e-spec.ts +++ b/server/test/e2e/person.e2e-spec.ts @@ -1,11 +1,11 @@ import { IPersonRepository, LoginResponseDto } from '@app/domain'; -import { AppModule, PersonController } from '@app/immich'; +import { PersonController } from '@app/immich'; import { PersonEntity } from '@app/infra/entities'; import { INestApplication } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; import { api } from '@test/api'; import { db } from '@test/db'; import { errorStub, uuidStub } from '@test/fixtures'; +import { createTestApp } from '@test/test-utils'; import request from 'supertest'; describe(`${PersonController.name}`, () => { @@ -18,11 +18,7 @@ describe(`${PersonController.name}`, () => { let hiddenPerson: PersonEntity; beforeAll(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModule], - }).compile(); - - app = await moduleFixture.createNestApplication().init(); + app = await createTestApp(); server = app.getHttpServer(); personRepository = app.get(IPersonRepository); }); diff --git a/server/test/e2e/server-info.e2e-spec.ts b/server/test/e2e/server-info.e2e-spec.ts index cd2814af3f..efdbbe5218 100644 --- a/server/test/e2e/server-info.e2e-spec.ts +++ b/server/test/e2e/server-info.e2e-spec.ts @@ -1,10 +1,10 @@ import { LoginResponseDto } from '@app/domain'; -import { AppModule, ServerInfoController } from '@app/immich'; +import { ServerInfoController } from '@app/immich'; import { INestApplication } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; import { api } from '@test/api'; import { db } from '@test/db'; import { errorStub } from '@test/fixtures'; +import { createTestApp } from '@test/test-utils'; import request from 'supertest'; describe(`${ServerInfoController.name} (e2e)`, () => { @@ -14,11 +14,7 @@ describe(`${ServerInfoController.name} (e2e)`, () => { let loginResponse: LoginResponseDto; beforeAll(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModule], - }).compile(); - - app = await moduleFixture.createNestApplication().init(); + app = await createTestApp(); server = app.getHttpServer(); }); @@ -81,9 +77,9 @@ describe(`${ServerInfoController.name} (e2e)`, () => { const { status, body } = await request(server).get('/server-info/features'); expect(status).toBe(200); expect(body).toEqual({ - clipEncode: true, + clipEncode: false, configFile: false, - facialRecognition: true, + facialRecognition: false, map: true, reverseGeocoding: true, oauth: false, @@ -91,7 +87,7 @@ describe(`${ServerInfoController.name} (e2e)`, () => { passwordLogin: true, search: false, sidecar: true, - tagImage: true, + tagImage: false, trash: true, }); }); diff --git a/server/test/e2e/setup.ts b/server/test/e2e/setup.ts index ce0aa348f1..26849f4686 100644 --- a/server/test/e2e/setup.ts +++ b/server/test/e2e/setup.ts @@ -1,21 +1,55 @@ import { PostgreSqlContainer } from '@testcontainers/postgresql'; -import { GenericContainer } from 'testcontainers'; +import * as fs from 'fs'; +import path from 'path'; + export default async () => { + 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 = ''; + + 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; + } + + const directoryExists = async (dirPath: string) => + await fs.promises + .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('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'; - - const pg = await new PostgreSqlContainer('postgres') - .withExposedPorts(5432) - .withDatabase('immich') - .withUsername('postgres') - .withPassword('postgres') - .withReuse() - .start(); - - process.env.DB_URL = pg.getConnectionUri(); - - const redis = await new GenericContainer('redis').withExposedPorts(6379).withReuse().start(); - - process.env.REDIS_PORT = String(redis.getMappedPort(6379)); - process.env.REDIS_HOSTNAME = redis.getHost(); + process.env.IMMICH_MACHINE_LEARNING_ENABLED = 'false'; + process.env.IMMICH_TEST_ENV = 'true'; + process.env.TZ = 'Z'; }; diff --git a/server/test/e2e/shared-link.e2e-spec.ts b/server/test/e2e/shared-link.e2e-spec.ts index 7d4c2639fb..2f88f7cefd 100644 --- a/server/test/e2e/shared-link.e2e-spec.ts +++ b/server/test/e2e/shared-link.e2e-spec.ts @@ -1,11 +1,11 @@ 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 { INestApplication } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; import { api } from '@test/api'; import { db } from '@test/db'; import { errorStub, uuidStub } from '@test/fixtures'; +import { createTestApp } from '@test/test-utils'; import request from 'supertest'; const user1Dto = { @@ -25,11 +25,7 @@ describe(`${PartnerController.name} (e2e)`, () => { let sharedLink: SharedLinkResponseDto; beforeAll(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModule], - }).compile(); - - app = await moduleFixture.createNestApplication().init(); + app = await createTestApp(); server = app.getHttpServer(); }); diff --git a/server/test/e2e/user.e2e-spec.ts b/server/test/e2e/user.e2e-spec.ts index 651aed9a70..9b976bc267 100644 --- a/server/test/e2e/user.e2e-spec.ts +++ b/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 { UserEntity } from '@app/infra/entities'; import { INestApplication } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; import { api } from '@test/api'; import { db } from '@test/db'; import { errorStub, userSignupStub, userStub } from '@test/fixtures'; +import { createTestApp } from '@test/test-utils'; import request from 'supertest'; import { Repository } from 'typeorm'; @@ -18,12 +18,9 @@ describe(`${UserController.name}`, () => { let userRepository: Repository; 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(); }); diff --git a/server/test/test-utils.ts b/server/test/test-utils.ts index 6f62ebd5c0..075e0b69fc 100644 --- a/server/test/test-utils.ts +++ b/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 { 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 = { 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 { + 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); +export const runAllTests: boolean = process.env.IMMICH_RUN_ALL_TESTS === 'true'; - expect(status).toBe(201); - expect(body).toEqual(signupResponseStub); +const directoryExists = async (dirPath: string) => + await fs.promises + .access(dirPath) + .then(() => true) + .catch(() => false); - 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) => { - 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 async function restoreTempFolder(): Promise { + 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); +}