diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 20faee34bde23..d6f803e61ed71 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,8 +10,25 @@ concurrency: cancel-in-progress: true jobs: - e2e-tests: - name: Server (e2e) + server-e2e-api: + name: Server (e2e-api) + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./server + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Run npm install + run: npm ci + + - name: Run e2e tests + run: npm run e2e:api + + server-e2e-jobs: + name: Server (e2e-jobs) runs-on: ubuntu-latest steps: @@ -21,7 +38,7 @@ jobs: submodules: "recursive" - name: Run e2e tests - run: make test-server-e2e + run: make server-e2e-jobs doc-tests: name: Docs diff --git a/Makefile b/Makefile index 699a0f7f1ae90..5d6302e390fcd 100644 --- a/Makefile +++ b/Makefile @@ -16,8 +16,8 @@ stage: pull-stage: docker compose -f ./docker/docker-compose.staging.yml pull -test-server-e2e: - docker compose -f ./server/test/docker-compose.server-e2e.yml up --renew-anon-volumes --abort-on-container-exit --exit-code-from immich-server --remove-orphans --build +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 prod: docker compose -f ./docker/docker-compose.prod.yml up --build -V --remove-orphans diff --git a/cli/test/e2e/login-key.e2e-spec.ts b/cli/test/e2e/login-key.e2e-spec.ts index e952808c92ba5..d1b8e085ae73f 100644 --- a/cli/test/e2e/login-key.e2e-spec.ts +++ b/cli/test/e2e/login-key.e2e-spec.ts @@ -1,7 +1,7 @@ -import { api } from '@test/api'; -import { restoreTempFolder, testApp } from 'immich/test/test-utils'; -import { LoginResponseDto } from 'src/api/open-api'; import { APIKeyCreateResponseDto } from '@app/domain'; +import { api } from '@test/../e2e/api/client'; +import { restoreTempFolder, testApp } from '@test/../e2e/jobs/utils'; +import { LoginResponseDto } from 'src/api/open-api'; import LoginKey from 'src/commands/login/key'; import { LoginError } from 'src/cores/errors/login-error'; import { CLI_BASE_OPTIONS, spyOnConsole } from 'test/cli-test-utils'; diff --git a/cli/test/e2e/server-info.e2e-spec.ts b/cli/test/e2e/server-info.e2e-spec.ts index bead36133ba5b..ef5050ee7483b 100644 --- a/cli/test/e2e/server-info.e2e-spec.ts +++ b/cli/test/e2e/server-info.e2e-spec.ts @@ -1,8 +1,8 @@ -import { api } from '@test/api'; -import { restoreTempFolder, testApp } from 'immich/test/test-utils'; +import { APIKeyCreateResponseDto } from '@app/domain'; +import { api } from '@test/../e2e/api/client'; +import { restoreTempFolder, testApp } from '@test/../e2e/jobs/utils'; import { LoginResponseDto } from 'src/api/open-api'; import ServerInfo from 'src/commands/server-info'; -import { APIKeyCreateResponseDto } from '@app/domain'; import { CLI_BASE_OPTIONS, spyOnConsole } from 'test/cli-test-utils'; describe(`server-info (e2e)`, () => { diff --git a/cli/test/e2e/setup.ts b/cli/test/e2e/setup.ts index 309c2c05d1a97..09872b3adfe2a 100644 --- a/cli/test/e2e/setup.ts +++ b/cli/test/e2e/setup.ts @@ -37,6 +37,6 @@ export default async () => { } process.env.NODE_ENV = 'development'; - process.env.IMMICH_CONFIG_FILE = path.normalize(`${__dirname}/../../../server/test/e2e/immich-e2e-config.json`); + process.env.IMMICH_CONFIG_FILE = path.normalize(`${__dirname}/../../../server/e2e/jobs/immich-e2e-config.json`); process.env.TZ = 'Z'; }; diff --git a/cli/test/e2e/upload.e2e-spec.ts b/cli/test/e2e/upload.e2e-spec.ts index 04b005f47c6b2..f685c8676e443 100644 --- a/cli/test/e2e/upload.e2e-spec.ts +++ b/cli/test/e2e/upload.e2e-spec.ts @@ -1,8 +1,8 @@ -import { api } from '@test/api'; -import { IMMICH_TEST_ASSET_PATH, restoreTempFolder, testApp } from 'immich/test/test-utils'; +import { APIKeyCreateResponseDto } from '@app/domain'; +import { api } from '@test/../e2e/api/client'; +import { IMMICH_TEST_ASSET_PATH, restoreTempFolder, testApp } from '@test/../e2e/jobs/utils'; import { LoginResponseDto } from 'src/api/open-api'; import Upload from 'src/commands/upload'; -import { APIKeyCreateResponseDto } from '@app/domain'; import { CLI_BASE_OPTIONS, spyOnConsole } from 'test/cli-test-utils'; describe(`upload (e2e)`, () => { diff --git a/docs/docs/developer/testing.md b/docs/docs/developer/testing.md index d9bb02962a183..7180bdaa2b662 100644 --- a/docs/docs/developer/testing.md +++ b/docs/docs/developer/testing.md @@ -14,4 +14,4 @@ Note that there is a bug in nodejs <20.8 that causes segmentation faults when ru 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 test:e2e`. +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`. diff --git a/server/test/api/activity-api.ts b/server/e2e/api/client/activity-api.ts similarity index 100% rename from server/test/api/activity-api.ts rename to server/e2e/api/client/activity-api.ts diff --git a/server/test/api/album-api.ts b/server/e2e/api/client/album-api.ts similarity index 100% rename from server/test/api/album-api.ts rename to server/e2e/api/client/album-api.ts diff --git a/server/test/api/api-key-api.ts b/server/e2e/api/client/api-key-api.ts similarity index 100% rename from server/test/api/api-key-api.ts rename to server/e2e/api/client/api-key-api.ts diff --git a/server/test/api/asset-api.ts b/server/e2e/api/client/asset-api.ts similarity index 100% rename from server/test/api/asset-api.ts rename to server/e2e/api/client/asset-api.ts diff --git a/server/test/api/auth-api.ts b/server/e2e/api/client/auth-api.ts similarity index 100% rename from server/test/api/auth-api.ts rename to server/e2e/api/client/auth-api.ts diff --git a/server/test/api/index.ts b/server/e2e/api/client/index.ts similarity index 100% rename from server/test/api/index.ts rename to server/e2e/api/client/index.ts diff --git a/server/test/api/library-api.ts b/server/e2e/api/client/library-api.ts similarity index 100% rename from server/test/api/library-api.ts rename to server/e2e/api/client/library-api.ts diff --git a/server/test/api/partner-api.ts b/server/e2e/api/client/partner-api.ts similarity index 100% rename from server/test/api/partner-api.ts rename to server/e2e/api/client/partner-api.ts diff --git a/server/test/api/server-info-api.ts b/server/e2e/api/client/server-info-api.ts similarity index 100% rename from server/test/api/server-info-api.ts rename to server/e2e/api/client/server-info-api.ts diff --git a/server/test/api/shared-link-api.ts b/server/e2e/api/client/shared-link-api.ts similarity index 100% rename from server/test/api/shared-link-api.ts rename to server/e2e/api/client/shared-link-api.ts diff --git a/server/test/api/user-api.ts b/server/e2e/api/client/user-api.ts similarity index 100% rename from server/test/api/user-api.ts rename to server/e2e/api/client/user-api.ts diff --git a/server/test/e2e/jest-e2e.json b/server/e2e/api/jest-e2e.json similarity index 86% rename from server/test/e2e/jest-e2e.json rename to server/e2e/api/jest-e2e.json index e860a7a6f34fe..a5d03b438f1d5 100644 --- a/server/test/e2e/jest-e2e.json +++ b/server/e2e/api/jest-e2e.json @@ -2,9 +2,9 @@ "moduleFileExtensions": ["js", "json", "ts"], "modulePaths": [""], "rootDir": "../..", - "globalSetup": "/test/e2e/setup.ts", + "globalSetup": "/e2e/api/setup.ts", "testEnvironment": "node", - "testRegex": ".e2e-spec.ts$", + "testMatch": ["**/e2e/api/specs/*.e2e-spec.[tj]s"], "testTimeout": 60000, "transform": { "^.+\\.(t|j)s$": "ts-jest" diff --git a/server/e2e/api/setup.ts b/server/e2e/api/setup.ts new file mode 100644 index 0000000000000..7223a1c0282bd --- /dev/null +++ b/server/e2e/api/setup.ts @@ -0,0 +1,16 @@ +import { PostgreSqlContainer } from '@testcontainers/postgresql'; + +export default async () => { + const pg = await new PostgreSqlContainer('tensorchord/pgvecto-rs:pg14-v0.1.11') + .withDatabase('immich') + .withUsername('postgres') + .withPassword('postgres') + .withReuse() + .withCommand(['-c', 'fsync=off', '-c', 'shared_preload_libraries=vectors.so']) + .start(); + + process.env.DB_URL = pg.getConnectionUri(); + process.env.NODE_ENV = 'development'; + process.env.LOG_LEVEL = 'fatal'; + process.env.TZ = 'Z'; +}; diff --git a/server/test/e2e/activity.e2e-spec.ts b/server/e2e/api/specs/activity.e2e-spec.ts similarity index 99% rename from server/test/e2e/activity.e2e-spec.ts rename to server/e2e/api/specs/activity.e2e-spec.ts index 2029e5a160e60..03c6799b5b60b 100644 --- a/server/test/e2e/activity.e2e-spec.ts +++ b/server/e2e/api/specs/activity.e2e-spec.ts @@ -2,10 +2,10 @@ import { AlbumResponseDto, LoginResponseDto, ReactionType } from '@app/domain'; import { ActivityController } from '@app/immich'; import { AssetFileUploadResponseDto } from '@app/immich/api-v1/asset/response-dto/asset-file-upload-response.dto'; import { ActivityEntity } from '@app/infra/entities'; -import { api } from '@test/api'; import { errorStub, userDto, uuidStub } from '@test/fixtures'; -import { testApp } from '@test/test-utils'; import request from 'supertest'; +import { api } from '../client'; +import { testApp } from '../utils'; describe(`${ActivityController.name} (e2e)`, () => { let server: any; diff --git a/server/test/e2e/album.e2e-spec.ts b/server/e2e/api/specs/album.e2e-spec.ts similarity index 99% rename from server/test/e2e/album.e2e-spec.ts rename to server/e2e/api/specs/album.e2e-spec.ts index 67dd546582987..d7dea11c788b7 100644 --- a/server/test/e2e/album.e2e-spec.ts +++ b/server/e2e/api/specs/album.e2e-spec.ts @@ -2,10 +2,10 @@ import { AlbumResponseDto, LoginResponseDto } from '@app/domain'; 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 { api } from '@test/api'; import { errorStub, userDto, uuidStub } from '@test/fixtures'; -import { testApp } from '@test/test-utils'; import request from 'supertest'; +import { api } from '../client'; +import { testApp } from '../utils'; const user1SharedUser = 'user1SharedUser'; const user1SharedLink = 'user1SharedLink'; diff --git a/server/test/e2e/asset.e2e-spec.ts b/server/e2e/api/specs/asset.e2e-spec.ts similarity index 99% rename from server/test/e2e/asset.e2e-spec.ts rename to server/e2e/api/specs/asset.e2e-spec.ts index 2ca47902f056d..783f3539b45ec 100644 --- a/server/test/e2e/asset.e2e-spec.ts +++ b/server/e2e/api/specs/asset.e2e-spec.ts @@ -13,11 +13,11 @@ import { AssetController } from '@app/immich'; import { AssetEntity, AssetType, SharedLinkType } from '@app/infra/entities'; import { AssetRepository } from '@app/infra/repositories'; import { INestApplication } from '@nestjs/common'; -import { api } from '@test/api'; import { errorStub, userDto, uuidStub } from '@test/fixtures'; -import { generateAsset, testApp, today, yesterday } from '@test/test-utils'; import { randomBytes } from 'crypto'; import request from 'supertest'; +import { api } from '../client'; +import { generateAsset, testApp, today, yesterday } from '../utils'; const makeUploadDto = (options?: { omit: string }): Record => { const dto: Record = { diff --git a/server/test/e2e/auth.e2e-spec.ts b/server/e2e/api/specs/auth.e2e-spec.ts similarity index 99% rename from server/test/e2e/auth.e2e-spec.ts rename to server/e2e/api/specs/auth.e2e-spec.ts index 2cf7c33dab185..bccece1bae1ca 100644 --- a/server/test/e2e/auth.e2e-spec.ts +++ b/server/e2e/api/specs/auth.e2e-spec.ts @@ -1,5 +1,4 @@ import { AuthController } from '@app/immich'; -import { api } from '@test/api'; import { adminSignupStub, changePasswordStub, @@ -9,8 +8,9 @@ import { loginStub, uuidStub, } from '@test/fixtures'; -import { testApp } from '@test/test-utils'; import request from 'supertest'; +import { api } from '../client'; +import { testApp } from '../utils'; const name = 'Immich Admin'; const password = 'Password123'; diff --git a/server/e2e/api/specs/library.e2e-spec.ts b/server/e2e/api/specs/library.e2e-spec.ts new file mode 100644 index 0000000000000..114c0a5bb7496 --- /dev/null +++ b/server/e2e/api/specs/library.e2e-spec.ts @@ -0,0 +1,387 @@ +import { LibraryResponseDto, LoginResponseDto } from '@app/domain'; +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 { testApp } from '../utils'; + +describe(`${LibraryController.name} (e2e)`, () => { + let server: any; + let admin: LoginResponseDto; + + beforeAll(async () => { + server = (await testApp.create()).getHttpServer(); + }); + + afterAll(async () => { + await testApp.teardown(); + }); + + beforeEach(async () => { + await testApp.reset(); + await api.authApi.adminSignUp(server); + admin = await api.authApi.adminLogin(server); + }); + + describe('GET /library', () => { + it('should require authentication', async () => { + const { status, body } = await request(server).get('/library'); + expect(status).toBe(401); + expect(body).toEqual(errorStub.unauthorized); + }); + + it('should start with a default upload library', async () => { + const { status, body } = await request(server) + .get('/library') + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(200); + expect(body).toHaveLength(1); + expect(body).toEqual([ + expect.objectContaining({ + ownerId: admin.userId, + type: LibraryType.UPLOAD, + name: 'Default Library', + refreshedAt: null, + assetCount: 0, + importPaths: [], + exclusionPatterns: [], + }), + ]); + }); + }); + + describe('POST /library', () => { + it('should require authentication', async () => { + const { status, body } = await request(server).post('/library').send({}); + expect(status).toBe(401); + expect(body).toEqual(errorStub.unauthorized); + }); + + it('should create an external library with defaults', async () => { + const { status, body } = await request(server) + .post('/library') + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ type: LibraryType.EXTERNAL }); + + 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('should create an external library with options', async () => { + const { status, body } = await request(server) + .post('/library') + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ + type: LibraryType.EXTERNAL, + name: 'My Awesome Library', + importPaths: ['/path/to/import'], + exclusionPatterns: ['**/Raw/**'], + }); + + expect(status).toBe(201); + expect(body).toEqual( + expect.objectContaining({ + name: 'My Awesome Library', + importPaths: ['/path/to/import'], + }), + ); + }); + + it('should create an upload library with defaults', async () => { + const { status, body } = await request(server) + .post('/library') + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ type: LibraryType.UPLOAD }); + + 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('should create an upload library with options', async () => { + const { status, body } = await request(server) + .post('/library') + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ type: LibraryType.UPLOAD, name: 'My Awesome Library' }); + + expect(status).toBe(201); + expect(body).toEqual( + expect.objectContaining({ + name: 'My Awesome Library', + }), + ); + }); + + it('should not allow upload libraries to have import paths', async () => { + const { status, body } = await request(server) + .post('/library') + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ type: LibraryType.UPLOAD, importPaths: ['/path/to/import'] }); + + expect(status).toBe(400); + expect(body).toEqual(errorStub.badRequest('Upload libraries cannot have import paths')); + }); + + it('should not allow upload libraries to have exclusion patterns', async () => { + const { status, body } = await request(server) + .post('/library') + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ type: LibraryType.UPLOAD, exclusionPatterns: ['**/Raw/**'] }); + + expect(status).toBe(400); + expect(body).toEqual(errorStub.badRequest('Upload libraries cannot have exclusion patterns')); + }); + + it('should allow a non-admin to create a library', async () => { + await api.userApi.create(server, admin.accessToken, userDto.user1); + const user1 = await api.authApi.login(server, userDto.user1); + + const { status, body } = await request(server) + .post('/library') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ type: LibraryType.EXTERNAL }); + + expect(status).toBe(201); + expect(body).toEqual( + expect.objectContaining({ + ownerId: user1.userId, + type: LibraryType.EXTERNAL, + name: 'New External Library', + refreshedAt: null, + assetCount: 0, + importPaths: [], + exclusionPatterns: [], + }), + ); + }); + }); + + describe('PUT /library/:id', () => { + it('should require authentication', async () => { + const { status, body } = await request(server).put(`/library/${uuidStub.notFound}`).send({}); + expect(status).toBe(401); + expect(body).toEqual(errorStub.unauthorized); + }); + + describe('external library', () => { + let library: LibraryResponseDto; + + beforeEach(async () => { + // Create an external library with default settings + 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/${library.id}`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ name: 'New Library Name' }); + + expect(status).toBe(200); + 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/${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/${library.id}`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ importPaths: ['/path/to/import'] }); + + expect(status).toBe(200); + 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/${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/${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/${library.id}`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ exclusionPatterns: [''] }); + + expect(status).toBe(400); + expect(body).toEqual(errorStub.badRequest(['each value in exclusionPatterns should not be empty'])); + }); + }); + }); + + 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 () => { + const library = await api.libraryApi.create(server, admin.accessToken, { type: LibraryType.EXTERNAL }); + + const { status, body } = await request(server) + .get(`/library/${library.id}`) + .set('Authorization', `Bearer ${admin.accessToken}`); + + expect(status).toBe(200); + 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 Promise.all([ + api.userApi.create(server, admin.accessToken, userDto.user1), + api.userApi.create(server, admin.accessToken, userDto.user2), + ]); + + const [user1, user2] = await Promise.all([ + api.authApi.login(server, userDto.user1), + api.authApi.login(server, userDto.user2), + ]); + + const library = await api.libraryApi.create(server, user1.accessToken, { type: LibraryType.EXTERNAL }); + + const { status, body } = await request(server) + .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')); + }); + }); + + 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, admin.accessToken); + expect(defaultLibrary).toBeDefined(); + + const { status, body } = await request(server) + .delete(`/library/${defaultLibrary.id}`) + .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, + }), + ]), + ); + }); + }); + + 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); + }); + }); + + 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); + }); + }); + + 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); + }); + }); +}); diff --git a/server/test/e2e/oauth.e2e-spec.ts b/server/e2e/api/specs/oauth.e2e-spec.ts similarity index 91% rename from server/test/e2e/oauth.e2e-spec.ts rename to server/e2e/api/specs/oauth.e2e-spec.ts index 422ced54bad3e..75bde10853e25 100644 --- a/server/test/e2e/oauth.e2e-spec.ts +++ b/server/e2e/api/specs/oauth.e2e-spec.ts @@ -1,8 +1,8 @@ import { OAuthController } from '@app/immich'; -import { api } from '@test/api'; import { errorStub } from '@test/fixtures'; -import { testApp } from '@test/test-utils'; import request from 'supertest'; +import { api } from '../client'; +import { testApp } from '../utils'; describe(`${OAuthController.name} (e2e)`, () => { let server: any; diff --git a/server/test/e2e/partner.e2e-spec.ts b/server/e2e/api/specs/partner.e2e-spec.ts similarity index 98% rename from server/test/e2e/partner.e2e-spec.ts rename to server/e2e/api/specs/partner.e2e-spec.ts index ac83e90cf964f..60c62a2af3c45 100644 --- a/server/test/e2e/partner.e2e-spec.ts +++ b/server/e2e/api/specs/partner.e2e-spec.ts @@ -1,9 +1,9 @@ import { LoginResponseDto, PartnerDirection } from '@app/domain'; import { PartnerController } from '@app/immich'; -import { api } from '@test/api'; import { errorStub, userDto } from '@test/fixtures'; -import { testApp } from '@test/test-utils'; import request from 'supertest'; +import { api } from '../client'; +import { testApp } from '../utils'; describe(`${PartnerController.name} (e2e)`, () => { let server: any; diff --git a/server/test/e2e/person.e2e-spec.ts b/server/e2e/api/specs/person.e2e-spec.ts similarity index 98% rename from server/test/e2e/person.e2e-spec.ts rename to server/e2e/api/specs/person.e2e-spec.ts index ab33b05b355cc..f8b8691d76a77 100644 --- a/server/test/e2e/person.e2e-spec.ts +++ b/server/e2e/api/specs/person.e2e-spec.ts @@ -2,10 +2,10 @@ import { IPersonRepository, LoginResponseDto } from '@app/domain'; import { PersonController } from '@app/immich'; import { PersonEntity } from '@app/infra/entities'; import { INestApplication } from '@nestjs/common'; -import { api } from '@test/api'; import { errorStub, uuidStub } from '@test/fixtures'; -import { testApp } from '@test/test-utils'; import request from 'supertest'; +import { api } from '../client'; +import { testApp } from '../utils'; describe(`${PersonController.name}`, () => { let app: INestApplication; diff --git a/server/test/e2e/search.e2e-spec.ts b/server/e2e/api/specs/search.e2e-spec.ts similarity index 98% rename from server/test/e2e/search.e2e-spec.ts rename to server/e2e/api/specs/search.e2e-spec.ts index 04b421c88c0d8..5d7241183856b 100644 --- a/server/test/e2e/search.e2e-spec.ts +++ b/server/e2e/api/specs/search.e2e-spec.ts @@ -8,10 +8,10 @@ import { } from '@app/domain'; import { SearchController } from '@app/immich'; import { INestApplication } from '@nestjs/common'; -import { api } from '@test/api'; import { errorStub, searchStub } from '@test/fixtures'; -import { generateAsset, testApp } from '@test/test-utils'; import request from 'supertest'; +import { api } from '../client'; +import { generateAsset, testApp } from '../utils'; describe(`${SearchController.name}`, () => { let app: INestApplication; diff --git a/server/test/e2e/server-info.e2e-spec.ts b/server/e2e/api/specs/server-info.e2e-spec.ts similarity index 96% rename from server/test/e2e/server-info.e2e-spec.ts rename to server/e2e/api/specs/server-info.e2e-spec.ts index fb0ad90c8975a..587f0a2cc49eb 100644 --- a/server/test/e2e/server-info.e2e-spec.ts +++ b/server/e2e/api/specs/server-info.e2e-spec.ts @@ -1,9 +1,9 @@ import { LoginResponseDto } from '@app/domain'; import { ServerInfoController } from '@app/immich'; -import { api } from '@test/api'; import { errorStub, userDto } from '@test/fixtures'; -import { testApp } from '@test/test-utils'; import request from 'supertest'; +import { api } from '../client'; +import { testApp } from '../utils'; describe(`${ServerInfoController.name} (e2e)`, () => { let server: any; @@ -73,11 +73,11 @@ describe(`${ServerInfoController.name} (e2e)`, () => { const { status, body } = await request(server).get('/server-info/features'); expect(status).toBe(200); expect(body).toEqual({ - clipEncode: false, - configFile: true, - facialRecognition: false, + clipEncode: true, + configFile: false, + facialRecognition: true, map: true, - reverseGeocoding: false, + reverseGeocoding: true, oauth: false, oauthAutoLaunch: false, passwordLogin: true, diff --git a/server/test/e2e/shared-link.e2e-spec.ts b/server/e2e/api/specs/shared-link.e2e-spec.ts similarity index 99% rename from server/test/e2e/shared-link.e2e-spec.ts rename to server/e2e/api/specs/shared-link.e2e-spec.ts index 80a4f11392cec..9d4bdae04e944 100644 --- a/server/test/e2e/shared-link.e2e-spec.ts +++ b/server/e2e/api/specs/shared-link.e2e-spec.ts @@ -8,11 +8,11 @@ import { import { SharedLinkController } from '@app/immich'; import { SharedLinkType } from '@app/infra/entities'; import { INestApplication } from '@nestjs/common'; -import { api } from '@test/api'; import { errorStub, userDto, uuidStub } from '@test/fixtures'; -import { testApp } from '@test/test-utils'; import { DateTime } from 'luxon'; import request from 'supertest'; +import { api } from '../client'; +import { testApp } from '../utils'; describe(`${SharedLinkController.name} (e2e)`, () => { let server: any; diff --git a/server/test/e2e/system-config.e2e-spec.ts b/server/e2e/api/specs/system-config.e2e-spec.ts similarity index 97% rename from server/test/e2e/system-config.e2e-spec.ts rename to server/e2e/api/specs/system-config.e2e-spec.ts index 3e9ae040d5e85..fe1470f7f5b0f 100644 --- a/server/test/e2e/system-config.e2e-spec.ts +++ b/server/e2e/api/specs/system-config.e2e-spec.ts @@ -1,9 +1,9 @@ import { LoginResponseDto } from '@app/domain'; import { SystemConfigController } from '@app/immich'; -import { api } from '@test/api'; import { errorStub, userDto } from '@test/fixtures'; -import { testApp } from '@test/test-utils'; import request from 'supertest'; +import { api } from '../client'; +import { testApp } from '../utils'; describe(`${SystemConfigController.name} (e2e)`, () => { let server: any; diff --git a/server/test/e2e/user.e2e-spec.ts b/server/e2e/api/specs/user.e2e-spec.ts similarity index 99% rename from server/test/e2e/user.e2e-spec.ts rename to server/e2e/api/specs/user.e2e-spec.ts index 6517629be8920..35a9a56e2b16a 100644 --- a/server/test/e2e/user.e2e-spec.ts +++ b/server/e2e/api/specs/user.e2e-spec.ts @@ -3,11 +3,11 @@ import { AppModule, UserController } from '@app/immich'; import { UserEntity } from '@app/infra/entities'; import { INestApplication } from '@nestjs/common'; import { getRepositoryToken } from '@nestjs/typeorm'; -import { api } from '@test/api'; import { errorStub, userDto, userSignupStub, userStub } from '@test/fixtures'; -import { testApp } from '@test/test-utils'; import request from 'supertest'; import { Repository } from 'typeorm'; +import { api } from '../client'; +import { testApp } from '../utils'; describe(`${UserController.name}`, () => { let app: INestApplication; diff --git a/server/e2e/api/utils.ts b/server/e2e/api/utils.ts new file mode 100644 index 0000000000000..d8027ab251b30 --- /dev/null +++ b/server/e2e/api/utils.ts @@ -0,0 +1,115 @@ +import { AssetCreate, IJobRepository, IMetadataRepository, LibraryResponseDto } from '@app/domain'; +import { AppModule } from '@app/immich'; +import { InfraModule, InfraTestModule, dataSource } from '@app/infra'; +import { AssetEntity, AssetType, LibraryType } from '@app/infra/entities'; +import { INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import { randomBytes } from 'crypto'; +import { DateTime } from 'luxon'; +import { EntityTarget, ObjectLiteral } from 'typeorm'; +import { AppService } from '../../src/microservices/app.service'; +import { newJobRepositoryMock, newMetadataRepositoryMock } from '../../test'; + +export const today = DateTime.fromObject({ year: 2023, month: 11, day: 3 }); +export const yesterday = today.minus({ days: 1 }); + +export interface ResetOptions { + entities?: EntityTarget[]; +} +export const db = { + reset: async (options?: ResetOptions) => { + if (!dataSource.isInitialized) { + await dataSource.initialize(); + } + await dataSource.transaction(async (em) => { + const entities = options?.entities || []; + const tableNames = + entities.length > 0 + ? entities.map((entity) => em.getRepository(entity).metadata.tableName) + : dataSource.entityMetadatas + .map((entity) => entity.tableName) + .filter((tableName) => !tableName.startsWith('geodata')); + + let deleteUsers = false; + for (const tableName of tableNames) { + if (tableName === 'users') { + deleteUsers = true; + continue; + } + await em.query(`DELETE FROM ${tableName} CASCADE;`); + } + if (deleteUsers) { + await em.query(`DELETE FROM "users" CASCADE;`); + } + }); + }, + disconnect: async () => { + if (dataSource.isInitialized) { + await dataSource.destroy(); + } + }, +}; + +let app: INestApplication; + +export const testApp = { + create: async (): Promise => { + const moduleFixture = await Test.createTestingModule({ imports: [AppModule], providers: [AppService] }) + .overrideModule(InfraModule) + .useModule(InfraTestModule) + .overrideProvider(IJobRepository) + .useValue(newJobRepositoryMock()) + .overrideProvider(IMetadataRepository) + .useValue(newMetadataRepositoryMock()) + .compile(); + + app = await moduleFixture.createNestApplication().init(); + await app.get(AppService).init(); + + return app; + }, + reset: async (options?: ResetOptions) => { + await db.reset(options); + }, + teardown: async () => { + if (app) { + await app.get(AppService).teardown(); + await app.close(); + } + await db.disconnect(); + }, +}; + +function randomDate(start: Date, end: Date): Date { + return new Date(start.getTime() + Math.random() * (end.getTime() - start.getTime())); +} + +let assetCount = 0; +export function generateAsset( + userId: string, + libraries: LibraryResponseDto[], + other: Partial = {}, +): AssetCreate { + const id = assetCount++; + const { fileCreatedAt = randomDate(new Date(1970, 1, 1), new Date(2023, 1, 1)) } = other; + + return { + createdAt: today.toJSDate(), + updatedAt: today.toJSDate(), + ownerId: userId, + checksum: randomBytes(20), + originalPath: `/tests/test_${id}`, + deviceAssetId: `test_${id}`, + deviceId: 'e2e-test', + libraryId: ( + libraries.find(({ ownerId, type }) => ownerId === userId && type === LibraryType.UPLOAD) as LibraryResponseDto + ).id, + isVisible: true, + fileCreatedAt, + fileModifiedAt: new Date(), + localDateTime: fileCreatedAt, + type: AssetType.IMAGE, + originalFileName: `test_${id}`, + ...other, + }; +} diff --git a/server/test/docker-compose.server-e2e.yml b/server/e2e/docker-compose.server-e2e.yml similarity index 89% rename from server/test/docker-compose.server-e2e.yml rename to server/e2e/docker-compose.server-e2e.yml index 350a7a924861b..854f59235149e 100644 --- a/server/test/docker-compose.server-e2e.yml +++ b/server/e2e/docker-compose.server-e2e.yml @@ -10,7 +10,7 @@ services: dockerfile: server/Dockerfile target: dev entrypoint: ['/usr/local/bin/npm', 'run'] - command: test:e2e + command: e2e:jobs volumes: - /usr/src/app/node_modules environment: @@ -24,6 +24,7 @@ services: database: image: tensorchord/pgvecto-rs:pg14-v0.1.11@sha256:0335a1a22f8c5dd1b697f14f079934f5152eaaa216c09b61e293be285491f8ee + command: -c fsync=off -c shared_preload_libraries=vectors.so environment: POSTGRES_PASSWORD: postgres POSTGRES_USER: postgres diff --git a/server/e2e/jobs/client/asset-api.ts b/server/e2e/jobs/client/asset-api.ts new file mode 100644 index 0000000000000..e40a8c150d4b8 --- /dev/null +++ b/server/e2e/jobs/client/asset-api.ts @@ -0,0 +1,79 @@ +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 new file mode 100644 index 0000000000000..3043c941f22c1 --- /dev/null +++ b/server/e2e/jobs/client/auth-api.ts @@ -0,0 +1,45 @@ +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 new file mode 100644 index 0000000000000..6040d026d7dbc --- /dev/null +++ b/server/e2e/jobs/client/index.ts @@ -0,0 +1,11 @@ +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 new file mode 100644 index 0000000000000..d70e7bd623937 --- /dev/null +++ b/server/e2e/jobs/client/library-api.ts @@ -0,0 +1,47 @@ +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 new file mode 100644 index 0000000000000..5ed0838f750b6 --- /dev/null +++ b/server/e2e/jobs/client/user-api.ts @@ -0,0 +1,50 @@ +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/test/e2e/immich-e2e-config.json b/server/e2e/jobs/immich-e2e-config.json similarity index 100% rename from server/test/e2e/immich-e2e-config.json rename to server/e2e/jobs/immich-e2e-config.json diff --git a/server/e2e/jobs/jest-e2e.json b/server/e2e/jobs/jest-e2e.json new file mode 100644 index 0000000000000..c7ebc60e0e45d --- /dev/null +++ b/server/e2e/jobs/jest-e2e.json @@ -0,0 +1,24 @@ +{ + "moduleFileExtensions": ["js", "json", "ts"], + "modulePaths": [""], + "rootDir": "../..", + "globalSetup": "/e2e/jobs/setup.ts", + "testEnvironment": "node", + "testMatch": ["**/e2e/jobs/specs/*.e2e-spec.[tj]s"], + "testTimeout": 60000, + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "collectCoverageFrom": [ + "/src/**/*.(t|j)s", + "!/src/**/*.spec.(t|s)s", + "!/src/infra/migrations/**" + ], + "coverageDirectory": "./coverage", + "moduleNameMapper": { + "^@test(|/.*)$": "/test/$1", + "^@app/immich(|/.*)$": "/src/immich/$1", + "^@app/infra(|/.*)$": "/src/infra/$1", + "^@app/domain(|/.*)$": "/src/domain/$1" + } +} diff --git a/server/test/e2e/setup.ts b/server/e2e/jobs/setup.ts similarity index 95% rename from server/test/e2e/setup.ts rename to server/e2e/jobs/setup.ts index f2065e85a1779..e3f9ef17fc0a5 100644 --- a/server/test/e2e/setup.ts +++ b/server/e2e/jobs/setup.ts @@ -16,7 +16,7 @@ export default async () => { let IMMICH_TEST_ASSET_PATH: string = ''; if (process.env.IMMICH_TEST_ASSET_PATH === undefined) { - IMMICH_TEST_ASSET_PATH = path.normalize(`${__dirname}/../assets/`); + IMMICH_TEST_ASSET_PATH = path.normalize(`${__dirname}/../../test/assets/`); process.env.IMMICH_TEST_ASSET_PATH = IMMICH_TEST_ASSET_PATH; } else { IMMICH_TEST_ASSET_PATH = process.env.IMMICH_TEST_ASSET_PATH; diff --git a/server/test/e2e/formats.e2e-spec.ts b/server/e2e/jobs/specs/formats.e2e-spec.ts similarity index 97% rename from server/test/e2e/formats.e2e-spec.ts rename to server/e2e/jobs/specs/formats.e2e-spec.ts index 7f25f2d76195d..5fdeebc943089 100644 --- a/server/test/e2e/formats.e2e-spec.ts +++ b/server/e2e/jobs/specs/formats.e2e-spec.ts @@ -1,7 +1,7 @@ import { LoginResponseDto } from '@app/domain'; import { AssetType, LibraryType } from '@app/infra/entities'; -import { api } from '@test/api'; -import { IMMICH_TEST_ASSET_PATH, runAllTests, testApp } from '@test/test-utils'; +import { api } from '../client'; +import { IMMICH_TEST_ASSET_PATH, runAllTests, testApp } from '../utils'; describe(`Supported file formats (e2e)`, () => { let server: any; diff --git a/server/test/e2e/library.e2e-spec.ts b/server/e2e/jobs/specs/library.e2e-spec.ts similarity index 61% rename from server/test/e2e/library.e2e-spec.ts rename to server/e2e/jobs/specs/library.e2e-spec.ts index bb31d6c8b0f23..1ed1355dfc84d 100644 --- a/server/test/e2e/library.e2e-spec.ts +++ b/server/e2e/jobs/specs/library.e2e-spec.ts @@ -1,12 +1,12 @@ import { LibraryResponseDto, LoginResponseDto } from '@app/domain'; import { LibraryController } from '@app/immich'; import { AssetType, LibraryType } from '@app/infra/entities'; -import { api } from '@test/api'; -import { IMMICH_TEST_ASSET_PATH, IMMICH_TEST_ASSET_TEMP_PATH, restoreTempFolder, testApp } from '@test/test-utils'; +import { errorStub, uuidStub } from '@test/fixtures'; import * as fs from 'fs'; import request from 'supertest'; import { utimes } from 'utimes'; -import { errorStub, userDto, uuidStub } from '../fixtures'; +import { api } from '../client'; +import { IMMICH_TEST_ASSET_PATH, IMMICH_TEST_ASSET_TEMP_PATH, restoreTempFolder, testApp } from '../utils'; describe(`${LibraryController.name} (e2e)`, () => { let server: any; @@ -28,365 +28,7 @@ describe(`${LibraryController.name} (e2e)`, () => { admin = await api.authApi.adminLogin(server); }); - describe('GET /library', () => { - it('should require authentication', async () => { - const { status, body } = await request(server).get('/library'); - expect(status).toBe(401); - expect(body).toEqual(errorStub.unauthorized); - }); - - it('should start with a default upload library', async () => { - const { status, body } = await request(server) - .get('/library') - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(200); - expect(body).toHaveLength(1); - expect(body).toEqual([ - expect.objectContaining({ - ownerId: admin.userId, - type: LibraryType.UPLOAD, - name: 'Default Library', - refreshedAt: null, - assetCount: 0, - importPaths: [], - exclusionPatterns: [], - }), - ]); - }); - }); - - describe('POST /library', () => { - it('should require authentication', async () => { - const { status, body } = await request(server).post('/library').send({}); - expect(status).toBe(401); - expect(body).toEqual(errorStub.unauthorized); - }); - - describe('external library', () => { - it('with default settings', async () => { - const { status, body } = await request(server) - .post('/library') - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ type: LibraryType.EXTERNAL }); - - 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 ${admin.accessToken}`) - .send({ type: LibraryType.EXTERNAL, name: 'My Awesome Library' }); - - 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 ${admin.accessToken}`) - .send({ type: LibraryType.EXTERNAL, importPaths: ['/path/to/import'] }); - - 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 ${admin.accessToken}`) - .send({ type: LibraryType.EXTERNAL, exclusionPatterns: ['**/Raw/**'] }); - - expect(status).toBe(201); - expect(body).toEqual( - expect.objectContaining({ - exclusionPatterns: ['**/Raw/**'], - }), - ); - }); - }); - - describe('upload library', () => { - it('with default settings', async () => { - const { status, body } = await request(server) - .post('/library') - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ type: LibraryType.UPLOAD }); - - 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 ${admin.accessToken}`) - .send({ type: LibraryType.UPLOAD, name: 'My Awesome Library' }); - - 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 ${admin.accessToken}`) - .send({ type: LibraryType.UPLOAD, importPaths: ['/path/to/import'] }); - - 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 ${admin.accessToken}`) - .send({ type: LibraryType.UPLOAD, exclusionPatterns: ['**/Raw/**'] }); - - 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.userApi.create(server, admin.accessToken, userDto.user1); - const user1 = await api.authApi.login(server, userDto.user1); - - const { status, body } = await request(server) - .post('/library') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ type: LibraryType.EXTERNAL }); - - expect(status).toBe(201); - expect(body).toEqual( - expect.objectContaining({ - ownerId: user1.userId, - type: LibraryType.EXTERNAL, - name: 'New External Library', - refreshedAt: null, - assetCount: 0, - importPaths: [], - exclusionPatterns: [], - }), - ); - }); - }); - - describe('PUT /library/:id', () => { - it('should require authentication', async () => { - const { status, body } = await request(server).put(`/library/${uuidStub.notFound}`).send({}); - expect(status).toBe(401); - expect(body).toEqual(errorStub.unauthorized); - }); - - describe('external library', () => { - let library: LibraryResponseDto; - - beforeEach(async () => { - // Create an external library with default settings - 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/${library.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ name: 'New Library Name' }); - - expect(status).toBe(200); - 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/${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/${library.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ importPaths: ['/path/to/import'] }); - - expect(status).toBe(200); - 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/${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/${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/${library.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ exclusionPatterns: [''] }); - - expect(status).toBe(400); - expect(body).toEqual(errorStub.badRequest(['each value in exclusionPatterns should not be empty'])); - }); - }); - }); - - 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 () => { - const library = await api.libraryApi.create(server, admin.accessToken, { type: LibraryType.EXTERNAL }); - - const { status, body } = await request(server) - .get(`/library/${library.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`); - - expect(status).toBe(200); - 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 Promise.all([ - api.userApi.create(server, admin.accessToken, userDto.user1), - api.userApi.create(server, admin.accessToken, userDto.user2), - ]); - - const [user1, user2] = await Promise.all([ - api.authApi.login(server, userDto.user1), - api.authApi.login(server, userDto.user2), - ]); - - const library = await api.libraryApi.create(server, user1.accessToken, { type: LibraryType.EXTERNAL }); - - const { status, body } = await request(server) - .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')); - }); - }); - 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, admin.accessToken); - expect(defaultLibrary).toBeDefined(); - - const { status, body } = await request(server) - .delete(`/library/${defaultLibrary.id}`) - .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 external library with assets', async () => { const library = await api.libraryApi.create(server, admin.accessToken, { type: LibraryType.EXTERNAL, @@ -418,15 +60,6 @@ describe(`${LibraryController.name} (e2e)`, () => { }); }); - 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); - }); - }); - describe('POST /library/:id/scan', () => { it('should require authentication', async () => { const { status, body } = await request(server).post(`/library/${uuidStub.notFound}/scan`).send({}); diff --git a/server/test/e2e/metadata.e2e-spec.ts b/server/e2e/jobs/specs/metadata.e2e-spec.ts similarity index 94% rename from server/test/e2e/metadata.e2e-spec.ts rename to server/e2e/jobs/specs/metadata.e2e-spec.ts index 7ed5ff97d64d7..160428108abb5 100644 --- a/server/test/e2e/metadata.e2e-spec.ts +++ b/server/e2e/jobs/specs/metadata.e2e-spec.ts @@ -1,9 +1,9 @@ import { AssetResponseDto, LoginResponseDto } from '@app/domain'; import { AssetController } from '@app/immich'; import { INestApplication } from '@nestjs/common'; -import { api } from '@test/api'; +import { exiftool } from 'exiftool-vendored'; import * as fs from 'fs'; - +import { api } from '../client'; import { IMMICH_TEST_ASSET_PATH, IMMICH_TEST_ASSET_TEMP_PATH, @@ -12,8 +12,7 @@ import { restoreTempFolder, runAllTests, testApp, -} from '@test/test-utils'; -import { exiftool } from 'exiftool-vendored'; +} from '../utils'; describe(`${AssetController.name} (e2e)`, () => { let app: INestApplication; @@ -33,8 +32,7 @@ describe(`${AssetController.name} (e2e)`, () => { }); afterAll(async () => { - await db.disconnect(); - await app.close(); + await testApp.teardown(); await restoreTempFolder(); }); @@ -82,8 +80,6 @@ describe(`${AssetController.name} (e2e)`, () => { const exifData = await exiftool.read(`${IMMICH_TEST_ASSET_TEMP_PATH}/thumbnail.jpg`); - console.log(assetWithLocation); - expect(exifData).not.toHaveProperty('GPSLongitude'); expect(exifData).not.toHaveProperty('GPSLatitude'); }); diff --git a/server/test/test-utils.ts b/server/e2e/jobs/utils.ts similarity index 98% rename from server/test/test-utils.ts rename to server/e2e/jobs/utils.ts index 3a91bc589aaf0..66ffc98fe25a9 100644 --- a/server/test/test-utils.ts +++ b/server/e2e/jobs/utils.ts @@ -10,7 +10,7 @@ import { DateTime } from 'luxon'; import path from 'path'; import { Server } from 'tls'; import { EntityTarget, ObjectLiteral } from 'typeorm'; -import { AppService } from '../src/microservices/app.service'; +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/`); diff --git a/server/package.json b/server/package.json index 95ab311005629..272c31144d0c0 100644 --- a/server/package.json +++ b/server/package.json @@ -22,7 +22,8 @@ "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": "NODE_OPTIONS='--experimental-vm-modules' jest --config test/e2e/jest-e2e.json --runInBand", + "e2e:jobs": "NODE_OPTIONS='--experimental-vm-modules' jest --config e2e/jobs/jest-e2e.json --runInBand", + "e2e:api": "jest --config e2e/api/jest-e2e.json --runInBand", "typeorm": "typeorm", "typeorm:migrations:create": "typeorm migration:create", "typeorm:migrations:generate": "typeorm migration:generate -d ./dist/infra/database.config.js", diff --git a/server/src/domain/job/job.service.spec.ts b/server/src/domain/job/job.service.spec.ts index 6dbbd9b6003be..7b912a111b539 100644 --- a/server/src/domain/job/job.service.spec.ts +++ b/server/src/domain/job/job.service.spec.ts @@ -2,7 +2,6 @@ import { SystemConfig, SystemConfigKey } from '@app/infra/entities'; import { BadRequestException } from '@nestjs/common'; import { assetStub, - asyncTick, newAssetRepositoryMock, newCommunicationRepositoryMock, newJobRepositoryMock, @@ -327,7 +326,6 @@ describe(JobService.name, () => { await sut.init(makeMockHandlers(true)); await jobMock.addHandler.mock.calls[0][2](item); - await asyncTick(3); if (jobs.length > 1) { expect(jobMock.queueAll).toHaveBeenCalledWith( @@ -344,7 +342,6 @@ describe(JobService.name, () => { it(`should not queue any jobs when ${item.name} finishes with 'false'`, async () => { await sut.init(makeMockHandlers(false)); await jobMock.addHandler.mock.calls[0][2](item); - await asyncTick(3); expect(jobMock.queueAll).not.toHaveBeenCalled(); }); diff --git a/server/test/index.ts b/server/test/index.ts index c63f82dca4688..784eeeb353c7c 100644 --- a/server/test/index.ts +++ b/server/test/index.ts @@ -1,8 +1,2 @@ export * from './fixtures'; export * from './repositories'; - -export async function asyncTick(steps: number) { - for (let i = 0; i < steps; i++) { - await Promise.resolve(); - } -}