diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 89c522ab6..48d5af118 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 f4f1c1e56..3c57a9ad5 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 7cdd1915b..a8b86d75c 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 68c179b42..000000000 --- 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 669062291..57b012334 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 000000000..ae3f68ce9 --- /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 fa5a232d5..bab48f199 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 d2e78fabe..be179baad 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 12e865775..be2ee7a90 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 993b3499a..dd137433a 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 0afb4f423..660aec60b 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 cbb89fcb0..9f2c25196 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 a3bcd1007..90477d8ca 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 48f9a8900..56d70cfb4 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 e433f6dc5..0c83b8abb 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 4c5a08aa9..d70e7bd62 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 20acf50c3..5ed0838f7 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 7f60d8124..633a825a7 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 a247409a9..fc057934a 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 bff6b976e..4068634e7 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 000000000..98e24ec9a --- /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 9a047176e..b7aa2a109 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 c2737f2a7..d0d2137c6 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 9283c11bc..b0eb1d4ce 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 49b94fd30..f9da56fa8 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 cd2814af3..efdbbe521 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 ce0aa348f..26849f468 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 7d4c2639f..2f88f7cef 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 651aed9a7..9b976bc26 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 6f62ebd5c..075e0b69f 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); +}