diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 89c522ab614ab..48d5af118d27c 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 f4f1c1e56d11c..3c57a9ad57096 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 7cdd1915b9918..a8b86d75c631c 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 68c179b42d78c..0000000000000 --- 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 6690622915703..57b01233405af 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 0000000000000..ae3f68ce991b3 --- /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 fa5a232d53baa..bab48f199dfe9 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 d2e78fabea676..be179baad484f 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 12e865775f8f3..be2ee7a9017fc 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 993b3499a3aa6..dd137433a0d29 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 0afb4f4237f42..660aec60bdfc5 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 cbb89fcb08820..9f2c25196b7ef 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 a3bcd10072ce3..90477d8ca3ce9 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 48f9a89007b0a..56d70cfb4e579 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 e433f6dc593b0..0c83b8abbd0af 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 4c5a08aa9222f..d70e7bd623937 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 20acf50c3801e..5ed0838f750b6 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 7f60d8124a299..633a825a76898 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 a247409a9c625..fc057934a919a 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 bff6b976ed2a3..4068634e74c3a 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 0000000000000..98e24ec9ac16c --- /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 9a047176e0193..b7aa2a109175f 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 c2737f2a76b00..d0d2137c64415 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 9283c11bcd87a..b0eb1d4ce7725 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 49b94fd30826f..f9da56fa81715 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 cd2814af3f1e0..efdbbe5218289 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 ce0aa348f16dc..26849f4686d66 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 7d4c2639fb8e7..2f88f7cefd863 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 651aed9a70efd..9b976bc267ec5 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 6f62ebd5c0534..075e0b69fca33 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); +}