diff --git a/Makefile b/Makefile index 39141ef4bd812..e66714a20f7ab 100644 --- a/Makefile +++ b/Makefile @@ -19,6 +19,9 @@ pull-stage: server-e2e-jobs: docker compose -f ./server/e2e/docker-compose.server-e2e.yml up --renew-anon-volumes --abort-on-container-exit --exit-code-from immich-server --remove-orphans --build +server-e2e-api: + npm run e2e:api --prefix server + prod: docker compose -f ./docker/docker-compose.prod.yml up --build -V --remove-orphans diff --git a/cli/test/e2e/jest-e2e.json b/cli/test/e2e/jest-e2e.json index 6146c0987663e..aee8fb458d957 100644 --- a/cli/test/e2e/jest-e2e.json +++ b/cli/test/e2e/jest-e2e.json @@ -16,6 +16,7 @@ ], "coverageDirectory": "./coverage", "moduleNameMapper": { + "^@api(|/.*)$": "../server/e2e/client/$1", "^@test(|/.*)$": "../server/test/$1", "^@app/immich(|/.*)$": "../server/src/immich/$1", "^@app/infra(|/.*)$": "../server/src/infra/$1", diff --git a/cli/test/e2e/login-key.e2e-spec.ts b/cli/test/e2e/login-key.e2e-spec.ts index cd41cf7294300..30e12616f626e 100644 --- a/cli/test/e2e/login-key.e2e-spec.ts +++ b/cli/test/e2e/login-key.e2e-spec.ts @@ -1,5 +1,5 @@ import { APIKeyCreateResponseDto } from '@app/domain'; -import { api } from '@test/../e2e/api/client'; +import { api } from '@api'; import { restoreTempFolder, testApp } from '@test/../e2e/jobs/utils'; import { LoginResponseDto } from '@immich/sdk'; import { LoginKey } from 'src/commands/login/key'; diff --git a/cli/test/e2e/server-info.e2e-spec.ts b/cli/test/e2e/server-info.e2e-spec.ts index ab5506491d46b..ed2e8d048353a 100644 --- a/cli/test/e2e/server-info.e2e-spec.ts +++ b/cli/test/e2e/server-info.e2e-spec.ts @@ -1,5 +1,5 @@ import { APIKeyCreateResponseDto } from '@app/domain'; -import { api } from '@test/../e2e/api/client'; +import { api } from '@api'; import { restoreTempFolder, testApp } from '@test/../e2e/jobs/utils'; import { LoginResponseDto } from '@immich/sdk'; import { ServerInfo } from 'src/commands/server-info'; diff --git a/cli/test/e2e/upload.e2e-spec.ts b/cli/test/e2e/upload.e2e-spec.ts index cd80b14b21a39..aef6a6f7f1a3d 100644 --- a/cli/test/e2e/upload.e2e-spec.ts +++ b/cli/test/e2e/upload.e2e-spec.ts @@ -1,5 +1,5 @@ import { APIKeyCreateResponseDto } from '@app/domain'; -import { api } from '@test/../e2e/api/client'; +import { api } from '@api'; import { IMMICH_TEST_ASSET_PATH, restoreTempFolder, testApp } from '@test/../e2e/jobs/utils'; import { LoginResponseDto } from '@immich/sdk'; import { Upload } from 'src/commands/upload'; diff --git a/cli/tsconfig.json b/cli/tsconfig.json index b570c1157be17..085c7596e49db 100644 --- a/cli/tsconfig.json +++ b/cli/tsconfig.json @@ -17,6 +17,8 @@ "rootDirs": ["src", "../server/src"], "baseUrl": "./", "paths": { + "@api": ["../server/e2e/client"], + "@api/*": ["../server/e2e/client/*"], "@test": ["../server/test"], "@test/*": ["../server/test/*"], "@app/immich": ["../server/src/immich"], diff --git a/docs/docs/developer/testing.md b/docs/docs/developer/testing.md index 27cbf71bb3104..9a17345a44834 100644 --- a/docs/docs/developer/testing.md +++ b/docs/docs/developer/testing.md @@ -8,10 +8,20 @@ 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. +The backend has two end-to-end test suites that can be called with the following two commands from the project root directory: +- `make server-e2e-api` +- `make server-e2e-jobs` + +#### API (e2e) + +The API e2e tests spin up a test database and execute http requests against the server, validating the expected response codes and functionality for API endpoints. + +#### Jobs (e2e) + +The Jobs e2e tests spin up a docker test environment where thumbnail generation, library scanning, and other _job_ workflows are validated. + +:::note 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 perform 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 e2e:jobs`. +::: +/follow diff --git a/server/bin/immich-test b/server/bin/immich-test new file mode 100755 index 0000000000000..e469e845bad83 --- /dev/null +++ b/server/bin/immich-test @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +NODE_OPTIONS='--experimental-vm-modules' node /usr/src/app/node_modules/.bin/jest --config e2e/$1/jest-e2e.json --runInBand diff --git a/server/e2e/api/specs/activity.e2e-spec.ts b/server/e2e/api/specs/activity.e2e-spec.ts index 03c6799b5b60b..47d2d7a1990fe 100644 --- a/server/e2e/api/specs/activity.e2e-spec.ts +++ b/server/e2e/api/specs/activity.e2e-spec.ts @@ -4,7 +4,7 @@ import { AssetFileUploadResponseDto } from '@app/immich/api-v1/asset/response-dt import { ActivityEntity } from '@app/infra/entities'; import { errorStub, userDto, uuidStub } from '@test/fixtures'; import request from 'supertest'; -import { api } from '../client'; +import { api } from '../../client'; import { testApp } from '../utils'; describe(`${ActivityController.name} (e2e)`, () => { diff --git a/server/e2e/api/specs/album.e2e-spec.ts b/server/e2e/api/specs/album.e2e-spec.ts index 0df753a846f03..312816035c659 100644 --- a/server/e2e/api/specs/album.e2e-spec.ts +++ b/server/e2e/api/specs/album.e2e-spec.ts @@ -4,7 +4,7 @@ import { AssetFileUploadResponseDto } from '@app/immich/api-v1/asset/response-dt import { SharedLinkType } from '@app/infra/entities'; import { errorStub, userDto, uuidStub } from '@test/fixtures'; import request from 'supertest'; -import { api } from '../client'; +import { api } from '../../client'; import { testApp } from '../utils'; const user1SharedUser = 'user1SharedUser'; diff --git a/server/e2e/api/specs/asset.e2e-spec.ts b/server/e2e/api/specs/asset.e2e-spec.ts index bd3d0a8a39b5b..4fc9a0e16bb9f 100644 --- a/server/e2e/api/specs/asset.e2e-spec.ts +++ b/server/e2e/api/specs/asset.e2e-spec.ts @@ -16,7 +16,7 @@ import { INestApplication } from '@nestjs/common'; import { errorStub, userDto, uuidStub } from '@test/fixtures'; import { randomBytes } from 'crypto'; import request from 'supertest'; -import { api } from '../client'; +import { api } from '../../client'; import { generateAsset, testApp, today, yesterday } from '../utils'; const makeUploadDto = (options?: { omit: string }): Record => { diff --git a/server/e2e/api/specs/auth.e2e-spec.ts b/server/e2e/api/specs/auth.e2e-spec.ts index 179fef79513da..eeb4723faa50e 100644 --- a/server/e2e/api/specs/auth.e2e-spec.ts +++ b/server/e2e/api/specs/auth.e2e-spec.ts @@ -9,7 +9,7 @@ import { uuidStub, } from '@test/fixtures'; import request from 'supertest'; -import { api } from '../client'; +import { api } from '../../client'; import { testApp } from '../utils'; const name = 'Immich Admin'; diff --git a/server/e2e/api/specs/library.e2e-spec.ts b/server/e2e/api/specs/library.e2e-spec.ts index 114c0a5bb7496..0ce192e479e4d 100644 --- a/server/e2e/api/specs/library.e2e-spec.ts +++ b/server/e2e/api/specs/library.e2e-spec.ts @@ -3,7 +3,7 @@ import { LibraryController } from '@app/immich'; import { LibraryType } from '@app/infra/entities'; import { errorStub, userDto, uuidStub } from '@test/fixtures'; import request from 'supertest'; -import { api } from '../client'; +import { api } from '../../client'; import { testApp } from '../utils'; describe(`${LibraryController.name} (e2e)`, () => { diff --git a/server/e2e/api/specs/oauth.e2e-spec.ts b/server/e2e/api/specs/oauth.e2e-spec.ts index 75bde10853e25..b6136971d0677 100644 --- a/server/e2e/api/specs/oauth.e2e-spec.ts +++ b/server/e2e/api/specs/oauth.e2e-spec.ts @@ -1,7 +1,7 @@ import { OAuthController } from '@app/immich'; import { errorStub } from '@test/fixtures'; import request from 'supertest'; -import { api } from '../client'; +import { api } from '../../client'; import { testApp } from '../utils'; describe(`${OAuthController.name} (e2e)`, () => { diff --git a/server/e2e/api/specs/partner.e2e-spec.ts b/server/e2e/api/specs/partner.e2e-spec.ts index 60c62a2af3c45..b254aa61f236e 100644 --- a/server/e2e/api/specs/partner.e2e-spec.ts +++ b/server/e2e/api/specs/partner.e2e-spec.ts @@ -2,7 +2,7 @@ import { LoginResponseDto, PartnerDirection } from '@app/domain'; import { PartnerController } from '@app/immich'; import { errorStub, userDto } from '@test/fixtures'; import request from 'supertest'; -import { api } from '../client'; +import { api } from '../../client'; import { testApp } from '../utils'; describe(`${PartnerController.name} (e2e)`, () => { diff --git a/server/e2e/api/specs/person.e2e-spec.ts b/server/e2e/api/specs/person.e2e-spec.ts index f8b8691d76a77..51c870428102f 100644 --- a/server/e2e/api/specs/person.e2e-spec.ts +++ b/server/e2e/api/specs/person.e2e-spec.ts @@ -4,7 +4,7 @@ import { PersonEntity } from '@app/infra/entities'; import { INestApplication } from '@nestjs/common'; import { errorStub, uuidStub } from '@test/fixtures'; import request from 'supertest'; -import { api } from '../client'; +import { api } from '../../client'; import { testApp } from '../utils'; describe(`${PersonController.name}`, () => { diff --git a/server/e2e/api/specs/search.e2e-spec.ts b/server/e2e/api/specs/search.e2e-spec.ts index 3b73b29524dac..8d17ee9304d29 100644 --- a/server/e2e/api/specs/search.e2e-spec.ts +++ b/server/e2e/api/specs/search.e2e-spec.ts @@ -10,7 +10,7 @@ import { SearchController } from '@app/immich'; import { INestApplication } from '@nestjs/common'; import { errorStub, searchStub } from '@test/fixtures'; import request from 'supertest'; -import { api } from '../client'; +import { api } from '../../client'; import { generateAsset, testApp } from '../utils'; describe(`${SearchController.name}`, () => { diff --git a/server/e2e/api/specs/server-info.e2e-spec.ts b/server/e2e/api/specs/server-info.e2e-spec.ts index 015fd53dcd932..d142704346512 100644 --- a/server/e2e/api/specs/server-info.e2e-spec.ts +++ b/server/e2e/api/specs/server-info.e2e-spec.ts @@ -2,7 +2,7 @@ import { LoginResponseDto } from '@app/domain'; import { ServerInfoController } from '@app/immich'; import { errorStub, userDto } from '@test/fixtures'; import request from 'supertest'; -import { api } from '../client'; +import { api } from '../../client'; import { testApp } from '../utils'; describe(`${ServerInfoController.name} (e2e)`, () => { diff --git a/server/e2e/api/specs/shared-link.e2e-spec.ts b/server/e2e/api/specs/shared-link.e2e-spec.ts index 9d4bdae04e944..034b2f2637edd 100644 --- a/server/e2e/api/specs/shared-link.e2e-spec.ts +++ b/server/e2e/api/specs/shared-link.e2e-spec.ts @@ -11,7 +11,7 @@ import { INestApplication } from '@nestjs/common'; import { errorStub, userDto, uuidStub } from '@test/fixtures'; import { DateTime } from 'luxon'; import request from 'supertest'; -import { api } from '../client'; +import { api } from '../../client'; import { testApp } from '../utils'; describe(`${SharedLinkController.name} (e2e)`, () => { diff --git a/server/e2e/api/specs/system-config.e2e-spec.ts b/server/e2e/api/specs/system-config.e2e-spec.ts index fe1470f7f5b0f..a6b7387091cba 100644 --- a/server/e2e/api/specs/system-config.e2e-spec.ts +++ b/server/e2e/api/specs/system-config.e2e-spec.ts @@ -2,7 +2,7 @@ import { LoginResponseDto } from '@app/domain'; import { SystemConfigController } from '@app/immich'; import { errorStub, userDto } from '@test/fixtures'; import request from 'supertest'; -import { api } from '../client'; +import { api } from '../../client'; import { testApp } from '../utils'; describe(`${SystemConfigController.name} (e2e)`, () => { diff --git a/server/e2e/api/specs/user.e2e-spec.ts b/server/e2e/api/specs/user.e2e-spec.ts index 35a9a56e2b16a..cd7c8147a3ddb 100644 --- a/server/e2e/api/specs/user.e2e-spec.ts +++ b/server/e2e/api/specs/user.e2e-spec.ts @@ -6,7 +6,7 @@ import { getRepositoryToken } from '@nestjs/typeorm'; import { errorStub, userDto, userSignupStub, userStub } from '@test/fixtures'; import request from 'supertest'; import { Repository } from 'typeorm'; -import { api } from '../client'; +import { api } from '../../client'; import { testApp } from '../utils'; describe(`${UserController.name}`, () => { diff --git a/server/e2e/api/client/activity-api.ts b/server/e2e/client/activity-api.ts similarity index 100% rename from server/e2e/api/client/activity-api.ts rename to server/e2e/client/activity-api.ts diff --git a/server/e2e/api/client/album-api.ts b/server/e2e/client/album-api.ts similarity index 100% rename from server/e2e/api/client/album-api.ts rename to server/e2e/client/album-api.ts diff --git a/server/e2e/api/client/api-key-api.ts b/server/e2e/client/api-key-api.ts similarity index 100% rename from server/e2e/api/client/api-key-api.ts rename to server/e2e/client/api-key-api.ts diff --git a/server/e2e/api/client/asset-api.ts b/server/e2e/client/asset-api.ts similarity index 87% rename from server/e2e/api/client/asset-api.ts rename to server/e2e/client/asset-api.ts index e40a8c150d4b8..b9ae897b33aa0 100644 --- a/server/e2e/api/client/asset-api.ts +++ b/server/e2e/client/asset-api.ts @@ -4,7 +4,7 @@ import { AssetFileUploadResponseDto } from '@app/immich/api-v1/asset/response-dt import { randomBytes } from 'crypto'; import request from 'supertest'; -type UploadDto = Partial & { content?: Buffer }; +type UploadDto = Partial & { content?: Buffer; filename?: string }; const asset = { deviceAssetId: 'test-1', @@ -45,19 +45,19 @@ export const assetApi = { 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; + upload: async (server: any, accessToken: string, deviceAssetId: string, dto: UploadDto = {}) => { + const { content, filename, isFavorite = false, isArchived = false } = dto; const { body, status } = await request(server) .post('/asset/upload') .set('Authorization', `Bearer ${accessToken}`) - .field('deviceAssetId', id) + .field('deviceAssetId', deviceAssetId) .field('deviceId', 'TEST') .field('fileCreatedAt', new Date().toISOString()) .field('fileModifiedAt', new Date().toISOString()) .field('isFavorite', isFavorite) .field('isArchived', isArchived) .field('duration', '0:00:00.000000') - .attach('assetData', content || randomBytes(32), 'example.jpg'); + .attach('assetData', content || randomBytes(32), filename || 'example.jpg'); expect(status).toBe(201); return body as AssetFileUploadResponseDto; diff --git a/server/e2e/api/client/auth-api.ts b/server/e2e/client/auth-api.ts similarity index 100% rename from server/e2e/api/client/auth-api.ts rename to server/e2e/client/auth-api.ts diff --git a/server/e2e/api/client/index.ts b/server/e2e/client/index.ts similarity index 100% rename from server/e2e/api/client/index.ts rename to server/e2e/client/index.ts diff --git a/server/e2e/api/client/library-api.ts b/server/e2e/client/library-api.ts similarity index 100% rename from server/e2e/api/client/library-api.ts rename to server/e2e/client/library-api.ts diff --git a/server/e2e/api/client/partner-api.ts b/server/e2e/client/partner-api.ts similarity index 100% rename from server/e2e/api/client/partner-api.ts rename to server/e2e/client/partner-api.ts diff --git a/server/e2e/api/client/server-info-api.ts b/server/e2e/client/server-info-api.ts similarity index 100% rename from server/e2e/api/client/server-info-api.ts rename to server/e2e/client/server-info-api.ts diff --git a/server/e2e/api/client/shared-link-api.ts b/server/e2e/client/shared-link-api.ts similarity index 100% rename from server/e2e/api/client/shared-link-api.ts rename to server/e2e/client/shared-link-api.ts diff --git a/server/e2e/api/client/user-api.ts b/server/e2e/client/user-api.ts similarity index 100% rename from server/e2e/api/client/user-api.ts rename to server/e2e/client/user-api.ts diff --git a/server/e2e/docker-compose.server-e2e.yml b/server/e2e/docker-compose.server-e2e.yml index 854f59235149e..c9d656cedbd04 100644 --- a/server/e2e/docker-compose.server-e2e.yml +++ b/server/e2e/docker-compose.server-e2e.yml @@ -9,8 +9,7 @@ services: context: ../../ dockerfile: server/Dockerfile target: dev - entrypoint: ['/usr/local/bin/npm', 'run'] - command: e2e:jobs + command: ['/usr/src/app/bin/immich-test', 'jobs'] volumes: - /usr/src/app/node_modules environment: @@ -18,7 +17,6 @@ services: - DB_USERNAME=postgres - DB_PASSWORD=postgres - DB_DATABASE_NAME=e2e_test - - IMMICH_RUN_ALL_TESTS=true depends_on: - database diff --git a/server/e2e/jobs/client/asset-api.ts b/server/e2e/jobs/client/asset-api.ts deleted file mode 100644 index e40a8c150d4b8..0000000000000 --- a/server/e2e/jobs/client/asset-api.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { AssetResponseDto } from '@app/domain'; -import { CreateAssetDto } from '@app/immich/api-v1/asset/dto/create-asset.dto'; -import { AssetFileUploadResponseDto } from '@app/immich/api-v1/asset/response-dto/asset-file-upload-response.dto'; -import { randomBytes } from 'crypto'; -import request from 'supertest'; - -type UploadDto = Partial & { content?: Buffer }; - -const asset = { - deviceAssetId: 'test-1', - deviceId: 'test', - fileCreatedAt: new Date(), - fileModifiedAt: new Date(), -}; - -export const assetApi = { - create: async ( - server: any, - accessToken: string, - dto?: Omit, - ): Promise => { - dto = dto || asset; - const { status, body } = await request(server) - .post(`/asset/upload`) - .field('deviceAssetId', dto.deviceAssetId) - .field('deviceId', dto.deviceId) - .field('fileCreatedAt', dto.fileCreatedAt.toISOString()) - .field('fileModifiedAt', dto.fileModifiedAt.toISOString()) - .attach('assetData', randomBytes(32), 'example.jpg') - .set('Authorization', `Bearer ${accessToken}`); - - expect([200, 201].includes(status)).toBe(true); - - return body as AssetResponseDto; - }, - 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) - .post('/asset/upload') - .set('Authorization', `Bearer ${accessToken}`) - .field('deviceAssetId', id) - .field('deviceId', 'TEST') - .field('fileCreatedAt', new Date().toISOString()) - .field('fileModifiedAt', new Date().toISOString()) - .field('isFavorite', isFavorite) - .field('isArchived', isArchived) - .field('duration', '0:00:00.000000') - .attach('assetData', content || randomBytes(32), 'example.jpg'); - - expect(status).toBe(201); - return body as AssetFileUploadResponseDto; - }, - getWebpThumbnail: async (server: any, accessToken: string, assetId: string) => { - const { body, status } = await request(server) - .get(`/asset/thumbnail/${assetId}`) - .set('Authorization', `Bearer ${accessToken}`); - expect(status).toBe(200); - return body; - }, - getJpegThumbnail: async (server: any, accessToken: string, assetId: string) => { - const { body, status } = await request(server) - .get(`/asset/thumbnail/${assetId}?format=JPEG`) - .set('Authorization', `Bearer ${accessToken}`); - expect(status).toBe(200); - return body; - }, -}; diff --git a/server/e2e/jobs/client/auth-api.ts b/server/e2e/jobs/client/auth-api.ts deleted file mode 100644 index 3043c941f22c1..0000000000000 --- a/server/e2e/jobs/client/auth-api.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { AuthDeviceResponseDto, LoginCredentialDto, LoginResponseDto, UserResponseDto } from '@app/domain'; -import { adminSignupStub, loginResponseStub, loginStub } from '@test'; -import request from 'supertest'; - -export const authApi = { - adminSignUp: async (server: any) => { - const { status, body } = await request(server).post('/auth/admin-sign-up').send(adminSignupStub); - - expect(status).toBe(201); - - return body as UserResponseDto; - }, - adminLogin: async (server: any) => { - const { status, body } = await request(server).post('/auth/login').send(loginStub.admin); - - expect(body).toEqual(loginResponseStub.admin.response); - expect(body).toMatchObject({ accessToken: expect.any(String) }); - expect(status).toBe(201); - - return body as LoginResponseDto; - }, - 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 { status, body } = await request(server) - .post('/auth/validateToken') - .set('Authorization', `Bearer ${accessToken}`); - expect(body).toEqual({ authStatus: true }); - expect(status).toBe(200); - }, -}; diff --git a/server/e2e/jobs/client/index.ts b/server/e2e/jobs/client/index.ts deleted file mode 100644 index 6040d026d7dbc..0000000000000 --- a/server/e2e/jobs/client/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { assetApi } from './asset-api'; -import { authApi } from './auth-api'; -import { libraryApi } from './library-api'; -import { userApi } from './user-api'; - -export const api = { - authApi, - assetApi, - libraryApi, - userApi, -}; diff --git a/server/e2e/jobs/client/library-api.ts b/server/e2e/jobs/client/library-api.ts deleted file mode 100644 index d70e7bd623937..0000000000000 --- a/server/e2e/jobs/client/library-api.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { CreateLibraryDto, LibraryResponseDto, LibraryStatsResponseDto, ScanLibraryDto } from '@app/domain'; -import request from 'supertest'; - -export const libraryApi = { - getAll: async (server: any, accessToken: string) => { - const { body, status } = await request(server).get(`/library/`).set('Authorization', `Bearer ${accessToken}`); - expect(status).toBe(200); - return body as LibraryResponseDto[]; - }, - create: async (server: any, accessToken: string, dto: CreateLibraryDto) => { - const { body, status } = await request(server) - .post(`/library/`) - .set('Authorization', `Bearer ${accessToken}`) - .send(dto); - expect(status).toBe(201); - return body as LibraryResponseDto; - }, - setImportPaths: async (server: any, accessToken: string, id: string, importPaths: string[]) => { - const { body, status } = await request(server) - .put(`/library/${id}`) - .set('Authorization', `Bearer ${accessToken}`) - .send({ importPaths }); - expect(status).toBe(200); - return body as LibraryResponseDto; - }, - scanLibrary: async (server: any, accessToken: string, id: string, dto: ScanLibraryDto = {}) => { - const { status } = await request(server) - .post(`/library/${id}/scan`) - .set('Authorization', `Bearer ${accessToken}`) - .send(dto); - expect(status).toBe(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/e2e/jobs/client/user-api.ts b/server/e2e/jobs/client/user-api.ts deleted file mode 100644 index 5ed0838f750b6..0000000000000 --- a/server/e2e/jobs/client/user-api.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { CreateUserDto, UpdateUserDto, UserResponseDto } from '@app/domain'; -import request from 'supertest'; - -export const 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; - }, - 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}`); - - expect(status).toBe(200); - expect(body).toMatchObject({ id, deletedAt: expect.any(String) }); - - return body as UserResponseDto; - }, -}; diff --git a/server/e2e/jobs/setup.ts b/server/e2e/jobs/setup.ts index e3f9ef17fc0a5..601d99cc28aef 100644 --- a/server/e2e/jobs/setup.ts +++ b/server/e2e/jobs/setup.ts @@ -3,16 +3,6 @@ import { access } from 'fs/promises'; 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 server 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) { diff --git a/server/e2e/jobs/specs/formats.e2e-spec.ts b/server/e2e/jobs/specs/formats.e2e-spec.ts index eb7f623c9736a..6f862c050d493 100644 --- a/server/e2e/jobs/specs/formats.e2e-spec.ts +++ b/server/e2e/jobs/specs/formats.e2e-spec.ts @@ -1,84 +1,43 @@ import { LoginResponseDto } from '@app/domain'; -import { AssetType, LibraryType } from '@app/infra/entities'; -import { api } from '../client'; -import { IMMICH_TEST_ASSET_PATH, runAllTests, testApp } from '../utils'; +import { AssetType } from '@app/infra/entities'; +import { readFile } from 'fs/promises'; +import { basename, join } from 'path'; +import { api } from '../../client'; +import { IMMICH_TEST_ASSET_PATH, testApp } from '../utils'; -describe(`Supported file formats (e2e)`, () => { - let server: any; - let admin: LoginResponseDto; +const JPEG = { + type: AssetType.IMAGE, + originalFileName: 'el_torcal_rocks', + resized: true, + exifInfo: { + 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, + description: 'SONY DSC', + }, +}; - 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, - description: 'SONY DSC', - }, - }, - { - 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, - description: 'SONY DSC', - }, - }, - { - format: 'heic', - path: 'heic', - runTest: runAllTests, - expectedAsset: { - type: AssetType.IMAGE, - originalFileName: 'IMG_2682', - resized: true, - fileCreatedAt: '2019-03-21T16:04:22.348Z', - }, - expectedExif: { +const tests = [ + { input: 'formats/jpg/el_torcal_rocks.jpg', expected: JPEG }, + { input: 'formats/jpeg/el_torcal_rocks.jpeg', expected: JPEG }, + { + input: 'formats/heic/IMG_2682.heic', + expected: { + type: AssetType.IMAGE, + originalFileName: 'IMG_2682', + resized: true, + fileCreatedAt: '2019-03-21T16:04:22.348Z', + exifInfo: { dateTimeOriginal: '2019-03-21T16:04:22.348Z', exifImageWidth: 4032, exifImageHeight: 3024, @@ -95,16 +54,14 @@ describe(`Supported file formats (e2e)`, () => { timeZone: 'America/Chicago', }, }, - { - format: 'png', - path: 'png', - runTest: true, - expectedAsset: { - type: AssetType.IMAGE, - originalFileName: 'density_plot', - resized: true, - }, - expectedExif: { + }, + { + input: 'formats/png/density_plot.png', + expected: { + type: AssetType.IMAGE, + originalFileName: 'density_plot', + resized: true, + exifInfo: { exifImageWidth: 800, exifImageHeight: 800, latitude: null, @@ -112,17 +69,15 @@ describe(`Supported file formats (e2e)`, () => { 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: { + }, + { + input: 'formats/raw/Nikon/D80/glarus.nef', + expected: { + type: AssetType.IMAGE, + originalFileName: 'glarus', + resized: true, + fileCreatedAt: '2010-07-20T17:27:12.000Z', + exifInfo: { make: 'NIKON CORPORATION', model: 'NIKON D80', exposureTime: '1/200', @@ -136,17 +91,15 @@ describe(`Supported file formats (e2e)`, () => { 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: { + }, + { + input: 'formats/raw/Nikon/D700/philadelphia.nef', + expected: { + type: AssetType.IMAGE, + originalFileName: 'philadelphia', + resized: true, + fileCreatedAt: '2016-09-22T22:10:29.060Z', + exifInfo: { make: 'NIKON CORPORATION', model: 'NIKON D700', exposureTime: '1/400', @@ -161,41 +114,45 @@ describe(`Supported file formats (e2e)`, () => { timeZone: 'UTC-5', }, }, - ]; + }, +]; - // Only run tests with runTest = true - const testsToRun = formatTests.filter((formatTest) => formatTest.runTest); +describe(`Format (e2e)`, () => { + let server: any; + let admin: LoginResponseDto; beforeAll(async () => { - server = (await testApp.create({ jobs: true })).getHttpServer(); - }); - - afterAll(async () => { - await testApp.teardown(); + const app = await testApp.create(); + server = app.getHttpServer(); }); beforeEach(async () => { await testApp.reset(); await api.authApi.adminSignUp(server); admin = await api.authApi.adminLogin(server); - await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, '/'); }); - 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}`], + afterAll(async () => { + await testApp.teardown(); + }); + + for (const { input, expected } of tests) { + it(`should generate a thumbnail for ${input}`, async () => { + const filepath = join(IMMICH_TEST_ASSET_PATH, input); + const content = await readFile(filepath); + await api.assetApi.upload(server, admin.accessToken, 'test-device-id', { + content, + filename: basename(filepath), + }); + const assets = await api.assetApi.getAllAssets(server, admin.accessToken); + + expect(assets).toHaveLength(1); + + const asset = assets[0]; + + expect(asset.exifInfo).toBeDefined(); + expect(asset.exifInfo).toMatchObject(expected.exifInfo); + expect(asset).toMatchObject(expected); }); - - 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/e2e/jobs/specs/library.e2e-spec.ts b/server/e2e/jobs/specs/library.e2e-spec.ts index 1ed1355dfc84d..21bf9942396ef 100644 --- a/server/e2e/jobs/specs/library.e2e-spec.ts +++ b/server/e2e/jobs/specs/library.e2e-spec.ts @@ -5,7 +5,7 @@ import { errorStub, uuidStub } from '@test/fixtures'; import * as fs from 'fs'; import request from 'supertest'; import { utimes } from 'utimes'; -import { api } from '../client'; +import { api } from '../../client'; import { IMMICH_TEST_ASSET_PATH, IMMICH_TEST_ASSET_TEMP_PATH, restoreTempFolder, testApp } from '../utils'; describe(`${LibraryController.name} (e2e)`, () => { @@ -13,7 +13,7 @@ describe(`${LibraryController.name} (e2e)`, () => { let admin: LoginResponseDto; beforeAll(async () => { - server = (await testApp.create({ jobs: true })).getHttpServer(); + server = (await testApp.create()).getHttpServer(); }); afterAll(async () => { @@ -148,48 +148,6 @@ describe(`${LibraryController.name} (e2e)`, () => { ); }); - 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, diff --git a/server/e2e/jobs/specs/metadata.e2e-spec.ts b/server/e2e/jobs/specs/metadata.e2e-spec.ts index 541e2d5505c2e..4a534b7ef9e93 100644 --- a/server/e2e/jobs/specs/metadata.e2e-spec.ts +++ b/server/e2e/jobs/specs/metadata.e2e-spec.ts @@ -2,17 +2,9 @@ import { AssetResponseDto, LoginResponseDto } from '@app/domain'; import { AssetController } from '@app/immich'; import { INestApplication } from '@nestjs/common'; import { exiftool } from 'exiftool-vendored'; -import * as fs from 'fs'; -import { api } from '../client'; -import { - IMMICH_TEST_ASSET_PATH, - IMMICH_TEST_ASSET_TEMP_PATH, - db, - itif, - restoreTempFolder, - runAllTests, - testApp, -} from '../utils'; +import { readFile, writeFile } from 'fs/promises'; +import { api } from '../../client'; +import { IMMICH_TEST_ASSET_PATH, IMMICH_TEST_ASSET_TEMP_PATH, db, restoreTempFolder, testApp } from '../utils'; describe(`${AssetController.name} (e2e)`, () => { let app: INestApplication; @@ -20,7 +12,7 @@ describe(`${AssetController.name} (e2e)`, () => { let admin: LoginResponseDto; beforeAll(async () => { - app = await testApp.create({ jobs: true }); + app = await testApp.create(); server = app.getHttpServer(); }); @@ -40,9 +32,7 @@ describe(`${AssetController.name} (e2e)`, () => { let assetWithLocation: AssetResponseDto; beforeEach(async () => { - const fileContent = await fs.promises.readFile( - `${IMMICH_TEST_ASSET_PATH}/metadata/gps-position/thompson-springs.jpg`, - ); + const fileContent = await readFile(`${IMMICH_TEST_ASSET_PATH}/metadata/gps-position/thompson-springs.jpg`); await api.assetApi.upload(server, admin.accessToken, 'test-asset-id', { content: fileContent }); @@ -58,12 +48,12 @@ describe(`${AssetController.name} (e2e)`, () => { ); }); - itif(runAllTests)('small webp thumbnails', async () => { + it('small webp thumbnails', async () => { const assetId = assetWithLocation.id; const thumbnail = await api.assetApi.getWebpThumbnail(server, admin.accessToken, assetId); - await fs.promises.writeFile(`${IMMICH_TEST_ASSET_TEMP_PATH}/thumbnail.webp`, thumbnail); + await writeFile(`${IMMICH_TEST_ASSET_TEMP_PATH}/thumbnail.webp`, thumbnail); const exifData = await exiftool.read(`${IMMICH_TEST_ASSET_TEMP_PATH}/thumbnail.webp`); @@ -71,12 +61,12 @@ describe(`${AssetController.name} (e2e)`, () => { expect(exifData).not.toHaveProperty('GPSLatitude'); }); - itif(runAllTests)('large jpeg thumbnails', async () => { + it('large jpeg thumbnails', async () => { const assetId = assetWithLocation.id; const thumbnail = await api.assetApi.getJpegThumbnail(server, admin.accessToken, assetId); - await fs.promises.writeFile(`${IMMICH_TEST_ASSET_TEMP_PATH}/thumbnail.jpg`, thumbnail); + await writeFile(`${IMMICH_TEST_ASSET_TEMP_PATH}/thumbnail.jpg`, thumbnail); const exifData = await exiftool.read(`${IMMICH_TEST_ASSET_TEMP_PATH}/thumbnail.jpg`); @@ -95,8 +85,8 @@ describe(`${AssetController.name} (e2e)`, () => { ['Samsung One UI 6.jpg', 'lT9Uviw/FFJYCjfIxAGPTjzAmmw='], ['Samsung One UI 6.heic', '/ejgzywvgvzvVhUYVfvkLzFBAF0='], ])('should extract motionphoto video', (file, checksum) => { - itif(runAllTests)(`with checksum ${checksum} from ${file}`, async () => { - const fileContent = await fs.promises.readFile(`${IMMICH_TEST_ASSET_PATH}/formats/motionphoto/${file}`); + it(`with checksum ${checksum} from ${file}`, async () => { + const fileContent = await readFile(`${IMMICH_TEST_ASSET_PATH}/formats/motionphoto/${file}`); const response = await api.assetApi.upload(server, admin.accessToken, 'test-asset-id', { content: fileContent }); const asset = await api.assetApi.get(server, admin.accessToken, response.id); diff --git a/server/e2e/jobs/utils.ts b/server/e2e/jobs/utils.ts index c0ca511d55176..8a098d90d789a 100644 --- a/server/e2e/jobs/utils.ts +++ b/server/e2e/jobs/utils.ts @@ -12,7 +12,7 @@ import { Server } from 'tls'; import { EntityTarget, ObjectLiteral } from 'typeorm'; import { AppService } from '../../src/microservices/app.service'; -export const IMMICH_TEST_ASSET_PATH = process.env.IMMICH_TEST_ASSET_PATH; +export const IMMICH_TEST_ASSET_PATH = process.env.IMMICH_TEST_ASSET_PATH as string; export const IMMICH_TEST_ASSET_TEMP_PATH = path.normalize(`${IMMICH_TEST_ASSET_PATH}/temp/`); export const today = DateTime.fromObject({ year: 2023, month: 11, day: 3 }); @@ -57,16 +57,10 @@ export const db = { let _handler: JobItemHandler = () => Promise.resolve(); -interface TestAppOptions { - jobs: boolean; -} - let app: INestApplication; export const testApp = { - create: async (options?: TestAppOptions): Promise => { - const { jobs } = options || { jobs: false }; - + create: async (): Promise => { const moduleFixture = await Test.createTestingModule({ imports: [AppModule], providers: [AppService] }) .overrideModule(InfraModule) .useModule(InfraTestModule) @@ -77,8 +71,8 @@ export const testApp = { updateCronJob: jest.fn(), deleteCronJob: jest.fn(), validateCronExpression: jest.fn(), - queue: (item: JobItem) => jobs && _handler(item), - queueAll: (items: JobItem[]) => jobs && Promise.all(items.map(_handler)).then(() => Promise.resolve()), + queue: (item: JobItem) => _handler(item), + queueAll: (items: JobItem[]) => Promise.all(items.map(_handler)).then(() => Promise.resolve()), resume: jest.fn(), empty: jest.fn(), setConcurrency: jest.fn(), @@ -113,10 +107,6 @@ export const testApp = { }, }; -export const runAllTests: boolean = process.env.IMMICH_RUN_ALL_TESTS === 'true'; - -export const itif = (condition: boolean) => (condition ? it : it.skip); - const directoryExists = async (dirPath: string) => await fs.promises .access(dirPath)