From 3d25d91e777fd071a527c163c364de372802800b Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 29 Feb 2024 15:10:08 -0500 Subject: [PATCH] refactor: library e2e (#7538) * refactor: library e2e * refactor: remove before each usages --- e2e/docker-compose.yml | 1 + e2e/src/api/specs/library.e2e-spec.ts | 456 +++++++++++++++++ e2e/src/utils.ts | 10 + e2e/vitest.config.ts | 10 +- open-api/immich-openapi-specs.json | 6 +- server/e2e/api/specs/library.e2e-spec.ts | 475 ------------------ server/e2e/client/library-api.ts | 4 +- server/e2e/jobs/specs/library.e2e-spec.ts | 9 +- .../immich/controllers/library.controller.ts | 5 +- 9 files changed, 489 insertions(+), 487 deletions(-) create mode 100644 e2e/src/api/specs/library.e2e-spec.ts delete mode 100644 server/e2e/api/specs/library.e2e-spec.ts diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index 44f79b55ac..6e2d4b78dd 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -16,6 +16,7 @@ x-server-build: &server-common - IMMICH_MACHINE_LEARNING_ENABLED=false volumes: - upload:/usr/src/app/upload + - ../server/test/assets:/data/assets depends_on: - redis - database diff --git a/e2e/src/api/specs/library.e2e-spec.ts b/e2e/src/api/specs/library.e2e-spec.ts new file mode 100644 index 0000000000..8213cc86ea --- /dev/null +++ b/e2e/src/api/specs/library.e2e-spec.ts @@ -0,0 +1,456 @@ +import { LibraryResponseDto, LibraryType, LoginResponseDto, getAllLibraries } from '@immich/sdk'; +import { userDto, uuidDto } from 'src/fixtures'; +import { errorDto } from 'src/responses'; +import { apiUtils, app, asBearerAuth, dbUtils, testAssetDirInternal } from 'src/utils'; +import request from 'supertest'; +import { beforeAll, describe, expect, it } from 'vitest'; + +describe('/library', () => { + let admin: LoginResponseDto; + let user: LoginResponseDto; + let library: LibraryResponseDto; + + beforeAll(async () => { + apiUtils.setup(); + await dbUtils.reset(); + admin = await apiUtils.adminSetup(); + user = await apiUtils.userSetup(admin.accessToken, userDto.user1); + library = await apiUtils.createLibrary(admin.accessToken, { type: LibraryType.External }); + }); + + describe('GET /library', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).get('/library'); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should start with a default upload library', async () => { + const { status, body } = await request(app).get('/library').set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(200); + expect(body).toEqual( + expect.arrayContaining([ + 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(app).post('/library').send({}); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should require admin authentication', async () => { + const { status, body } = await request(app) + .post('/library') + .set('Authorization', `Bearer ${user.accessToken}`) + .send({ type: LibraryType.External }); + + expect(status).toBe(403); + expect(body).toEqual(errorDto.forbidden); + }); + + it('should create an external library with defaults', async () => { + const { status, body } = await request(app) + .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(app) + .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 not create an external library with duplicate import paths', async () => { + const { status, body } = await request(app) + .post('/library') + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ + type: LibraryType.External, + name: 'My Awesome Library', + importPaths: ['/path', '/path'], + exclusionPatterns: ['**/Raw/**'], + }); + + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(["All importPaths's elements must be unique"])); + }); + + it('should not create an external library with duplicate exclusion patterns', async () => { + const { status, body } = await request(app) + .post('/library') + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ + type: LibraryType.External, + name: 'My Awesome Library', + importPaths: ['/path/to/import'], + exclusionPatterns: ['**/Raw/**', '**/Raw/**'], + }); + + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(["All exclusionPatterns's elements must be unique"])); + }); + + it('should create an upload library with defaults', async () => { + const { status, body } = await request(app) + .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(app) + .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(app) + .post('/library') + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ type: LibraryType.Upload, importPaths: ['/path/to/import'] }); + + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest('Upload libraries cannot have import paths')); + }); + + it('should not allow upload libraries to have exclusion patterns', async () => { + const { status, body } = await request(app) + .post('/library') + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ type: LibraryType.Upload, exclusionPatterns: ['**/Raw/**'] }); + + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest('Upload libraries cannot have exclusion patterns')); + }); + }); + + describe('PUT /library/:id', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).put(`/library/${uuidDto.notFound}`).send({}); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should change the library name', async () => { + const { status, body } = await request(app) + .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(app) + .put(`/library/${library.id}`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ name: '' }); + + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(['name should not be empty'])); + }); + + it('should change the import paths', async () => { + const { status, body } = await request(app) + .put(`/library/${library.id}`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ importPaths: [testAssetDirInternal] }); + + expect(status).toBe(200); + expect(body).toEqual( + expect.objectContaining({ + importPaths: [testAssetDirInternal], + }), + ); + }); + + it('should reject an empty import path', async () => { + const { status, body } = await request(app) + .put(`/library/${library.id}`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ importPaths: [''] }); + + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(['each value in importPaths should not be empty'])); + }); + + it('should reject duplicate import paths', async () => { + const { status, body } = await request(app) + .put(`/library/${library.id}`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ importPaths: ['/path', '/path'] }); + + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(["All importPaths's elements must be unique"])); + }); + + it('should change the exclusion pattern', async () => { + const { status, body } = await request(app) + .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 reject duplicate exclusion patterns', async () => { + const { status, body } = await request(app) + .put(`/library/${library.id}`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ exclusionPatterns: ['**/*.jpg', '**/*.jpg'] }); + + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(["All exclusionPatterns's elements must be unique"])); + }); + + it('should reject an empty exclusion pattern', async () => { + const { status, body } = await request(app) + .put(`/library/${library.id}`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ exclusionPatterns: [''] }); + + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(['each value in exclusionPatterns should not be empty'])); + }); + }); + + describe('GET /library/:id', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).get(`/library/${uuidDto.notFound}`); + + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should require admin access', async () => { + const { status, body } = await request(app) + .get(`/library/${uuidDto.notFound}`) + .set('Authorization', `Bearer ${user.accessToken}`); + expect(status).toBe(403); + expect(body).toEqual(errorDto.forbidden); + }); + + it('should get library by id', async () => { + const library = await apiUtils.createLibrary(admin.accessToken, { type: LibraryType.External }); + + const { status, body } = await request(app) + .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: [], + }), + ); + }); + }); + + describe('DELETE /library/:id', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).delete(`/library/${uuidDto.notFound}`); + + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should not delete the last upload library', async () => { + const libraries = await getAllLibraries( + { $type: LibraryType.Upload }, + { headers: asBearerAuth(admin.accessToken) }, + ); + + const adminLibraries = libraries.filter((library) => library.ownerId === admin.userId); + expect(adminLibraries.length).toBeGreaterThanOrEqual(1); + const lastLibrary = adminLibraries.pop() as LibraryResponseDto; + + // delete all but the last upload library + for (const library of adminLibraries) { + const { status } = await request(app) + .delete(`/library/${library.id}`) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(204); + } + + const { status, body } = await request(app) + .delete(`/library/${lastLibrary.id}`) + .set('Authorization', `Bearer ${admin.accessToken}`); + + expect(body).toEqual(errorDto.noDeleteUploadLibrary); + expect(status).toBe(400); + }); + + it('should delete an external library', async () => { + const library = await apiUtils.createLibrary(admin.accessToken, { type: LibraryType.External }); + + const { status, body } = await request(app) + .delete(`/library/${library.id}`) + .set('Authorization', `Bearer ${admin.accessToken}`); + + expect(status).toBe(204); + expect(body).toEqual({}); + + const libraries = await getAllLibraries({}, { headers: asBearerAuth(admin.accessToken) }); + 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(app).get(`/library/${uuidDto.notFound}/statistics`); + + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + }); + + describe('POST /library/:id/scan', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).post(`/library/${uuidDto.notFound}/scan`).send({}); + + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + }); + + describe('POST /library/:id/removeOffline', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).post(`/library/${uuidDto.notFound}/removeOffline`).send({}); + + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + }); + + describe('POST /library/:id/validate', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).post(`/library/${uuidDto.notFound}/validate`).send({}); + + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should pass with no import paths', async () => { + const response = await apiUtils.validateLibrary(admin.accessToken, library.id, { importPaths: [] }); + expect(response.importPaths).toEqual([]); + }); + + it('should fail if path does not exist', async () => { + const pathToTest = `${testAssetDirInternal}/does/not/exist`; + + const response = await apiUtils.validateLibrary(admin.accessToken, library.id, { + importPaths: [pathToTest], + }); + + expect(response.importPaths?.length).toEqual(1); + const pathResponse = response?.importPaths?.at(0); + + expect(pathResponse).toEqual({ + importPath: pathToTest, + isValid: false, + message: `Path does not exist (ENOENT)`, + }); + }); + + it('should fail if path is a file', async () => { + const pathToTest = `${testAssetDirInternal}/albums/nature/el_torcal_rocks.jpg`; + + const response = await apiUtils.validateLibrary(admin.accessToken, library.id, { + importPaths: [pathToTest], + }); + + expect(response.importPaths?.length).toEqual(1); + const pathResponse = response?.importPaths?.at(0); + + expect(pathResponse).toEqual({ + importPath: pathToTest, + isValid: false, + message: `Not a directory`, + }); + }); + }); +}); diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index b02e0053f3..34f25e396d 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -3,11 +3,14 @@ import { AssetResponseDto, CreateAlbumDto, CreateAssetDto, + CreateLibraryDto, CreateUserDto, PersonUpdateDto, SharedLinkCreateDto, + ValidateLibraryDto, createAlbum, createApiKey, + createLibrary, createPerson, createSharedLink, createUser, @@ -18,6 +21,7 @@ import { setAdminOnboarding, signUpAdmin, updatePerson, + validate, } from '@immich/sdk'; import { BrowserContext } from '@playwright/test'; import { exec, spawn } from 'node:child_process'; @@ -42,6 +46,7 @@ const directoryExists = (directory: string) => // TODO move test assets into e2e/assets export const testAssetDir = path.resolve(`./../server/test/assets/`); +export const testAssetDirInternal = '/data/assets'; export const tempDir = tmpdir(); const serverContainerName = 'immich-e2e-server'; @@ -103,6 +108,7 @@ export const dbUtils = { } tables = tables || [ + 'libraries', 'shared_links', 'person', 'albums', @@ -313,6 +319,10 @@ export const apiUtils = { }, createSharedLink: (accessToken: string, dto: SharedLinkCreateDto) => createSharedLink({ sharedLinkCreateDto: dto }, { headers: asBearerAuth(accessToken) }), + createLibrary: (accessToken: string, dto: CreateLibraryDto) => + createLibrary({ createLibraryDto: dto }, { headers: asBearerAuth(accessToken) }), + validateLibrary: (accessToken: string, id: string, dto: ValidateLibraryDto) => + validate({ id, validateLibraryDto: dto }, { headers: asBearerAuth(accessToken) }), }; export const cliUtils = { diff --git a/e2e/vitest.config.ts b/e2e/vitest.config.ts index 72c126a899..b8cc098ddd 100644 --- a/e2e/vitest.config.ts +++ b/e2e/vitest.config.ts @@ -1,9 +1,17 @@ import { defineConfig } from 'vitest/config'; +// skip `docker compose up` if `make e2e` was already run +const globalSetup: string[] = []; +try { + await fetch('http://127.0.0.1:2283/api/server-info/ping'); +} catch { + globalSetup.push('src/setup.ts'); +} + export default defineConfig({ test: { include: ['src/{api,cli}/specs/*.e2e-spec.ts'], - globalSetup: ['src/setup.ts'], + globalSetup, poolOptions: { threads: { singleThread: true, diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 3092c6cc63..f38780f294 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -3396,7 +3396,7 @@ } ], "responses": { - "200": { + "204": { "description": "" } }, @@ -3521,7 +3521,7 @@ } ], "responses": { - "201": { + "204": { "description": "" } }, @@ -3566,7 +3566,7 @@ "required": true }, "responses": { - "201": { + "204": { "description": "" } }, diff --git a/server/e2e/api/specs/library.e2e-spec.ts b/server/e2e/api/specs/library.e2e-spec.ts deleted file mode 100644 index edb0a9feb7..0000000000 --- a/server/e2e/api/specs/library.e2e-spec.ts +++ /dev/null @@ -1,475 +0,0 @@ -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 { IMMICH_TEST_ASSET_TEMP_PATH, restoreTempFolder } from 'src/test-utils/utils'; -import request from 'supertest'; -import { api } from '../../client'; -import { testApp } from '../utils'; - -describe(`${LibraryController.name} (e2e)`, () => { - let server: any; - let admin: LoginResponseDto; - let user: LoginResponseDto; - - beforeAll(async () => { - const app = await testApp.create(); - server = app.getHttpServer(); - }); - - afterAll(async () => { - await testApp.teardown(); - }); - - beforeEach(async () => { - await restoreTempFolder(); - await testApp.reset(); - await api.authApi.adminSignUp(server); - admin = await api.authApi.adminLogin(server); - - await api.userApi.create(server, admin.accessToken, userDto.user1); - user = await api.authApi.login(server, userDto.user1); - }); - - 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).toEqual( - expect.arrayContaining([ - 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 require admin authentication', async () => { - const { status, body } = await request(server) - .post('/library') - .set('Authorization', `Bearer ${user.accessToken}`) - .send({ type: LibraryType.EXTERNAL }); - - expect(status).toBe(403); - expect(body).toEqual(errorStub.forbidden); - }); - - 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 not create an external library with duplicate import paths', async () => { - const { status, body } = await request(server) - .post('/library') - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ - type: LibraryType.EXTERNAL, - name: 'My Awesome Library', - importPaths: ['/path', '/path'], - exclusionPatterns: ['**/Raw/**'], - }); - - expect(status).toBe(400); - expect(body).toEqual(errorStub.badRequest(["All importPaths's elements must be unique"])); - }); - - it('should not create an external library with duplicate exclusion patterns', 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/**', '**/Raw/**'], - }); - - expect(status).toBe(400); - expect(body).toEqual(errorStub.badRequest(["All exclusionPatterns's elements must be unique"])); - }); - - 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')); - }); - }); - - 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: [IMMICH_TEST_ASSET_TEMP_PATH] }); - - expect(status).toBe(200); - expect(body).toEqual( - expect.objectContaining({ - importPaths: [IMMICH_TEST_ASSET_TEMP_PATH], - }), - ); - }); - - it('should reject 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 reject duplicate import paths', async () => { - const { status, body } = await request(server) - .put(`/library/${library.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ importPaths: ['/path', '/path'] }); - - expect(status).toBe(400); - expect(body).toEqual(errorStub.badRequest(["All importPaths's elements must be unique"])); - }); - - 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 reject duplicate exclusion patterns', async () => { - const { status, body } = await request(server) - .put(`/library/${library.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ exclusionPatterns: ['**/*.jpg', '**/*.jpg'] }); - - expect(status).toBe(400); - expect(body).toEqual(errorStub.badRequest(["All exclusionPatterns's elements must be unique"])); - }); - - it('should reject 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 require admin access', async () => { - const { status, body } = await request(server) - .get(`/library/${uuidStub.notFound}`) - .set('Authorization', `Bearer ${user.accessToken}`); - expect(status).toBe(403); - expect(body).toEqual(errorStub.forbidden); - }); - - 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: [], - }), - ); - }); - }); - - 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 external 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).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); - }); - }); - - describe('POST /library/:id/validate', () => { - it('should require authentication', async () => { - const { status, body } = await request(server).post(`/library/${uuidStub.notFound}/validate`).send({}); - - expect(status).toBe(401); - expect(body).toEqual(errorStub.unauthorized); - }); - - describe('Validate import path', () => { - 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 pass with no import paths', async () => { - const response = await api.libraryApi.validate(server, admin.accessToken, library.id, { importPaths: [] }); - expect(response.importPaths).toEqual([]); - }); - - it('should fail if path does not exist', async () => { - const pathToTest = `${IMMICH_TEST_ASSET_TEMP_PATH}/does/not/exist`; - - const response = await api.libraryApi.validate(server, admin.accessToken, library.id, { - importPaths: [pathToTest], - }); - - expect(response.importPaths?.length).toEqual(1); - const pathResponse = response?.importPaths?.at(0); - - expect(pathResponse).toEqual({ - importPath: pathToTest, - isValid: false, - message: `Path does not exist (ENOENT)`, - }); - }); - - it('should fail if path is a file', async () => { - const pathToTest = `${IMMICH_TEST_ASSET_TEMP_PATH}/does/not/exist`; - - const response = await api.libraryApi.validate(server, admin.accessToken, library.id, { - importPaths: [pathToTest], - }); - - expect(response.importPaths?.length).toEqual(1); - const pathResponse = response?.importPaths?.at(0); - - expect(pathResponse).toEqual({ - importPath: pathToTest, - isValid: false, - message: `Path does not exist (ENOENT)`, - }); - }); - }); - }); -}); diff --git a/server/e2e/client/library-api.ts b/server/e2e/client/library-api.ts index 9f2d0b77ef..e0b1331267 100644 --- a/server/e2e/client/library-api.ts +++ b/server/e2e/client/library-api.ts @@ -36,14 +36,14 @@ export const libraryApi = { .post(`/library/${id}/scan`) .set('Authorization', `Bearer ${accessToken}`) .send(dto); - expect(status).toBe(201); + expect(status).toBe(204); }, 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); + expect(status).toBe(204); }, getLibraryStatistics: async (server: any, accessToken: string, id: string): Promise => { const { body, status } = await request(server) diff --git a/server/e2e/jobs/specs/library.e2e-spec.ts b/server/e2e/jobs/specs/library.e2e-spec.ts index 33208fde29..0657227f8d 100644 --- a/server/e2e/jobs/specs/library.e2e-spec.ts +++ b/server/e2e/jobs/specs/library.e2e-spec.ts @@ -45,12 +45,11 @@ describe(`${LibraryController.name} (e2e)`, () => { const assets = await api.assetApi.getAllAssets(server, admin.accessToken); expect(assets.length).toBeGreaterThan(2); - const { status, body } = await request(server) + const { status } = await request(server) .delete(`/library/${library.id}`) .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(200); - expect(body).toEqual({}); + expect(status).toBe(204); const libraries = await api.libraryApi.getAll(server, admin.accessToken); expect(libraries).toHaveLength(1); @@ -392,7 +391,7 @@ describe(`${LibraryController.name} (e2e)`, () => { .post(`/library/${library.id}/removeOffline`) .set('Authorization', `Bearer ${admin.accessToken}`) .send(); - expect(status).toBe(201); + expect(status).toBe(204); const assets = await api.assetApi.getAllAssets(server, admin.accessToken); @@ -416,7 +415,7 @@ describe(`${LibraryController.name} (e2e)`, () => { .post(`/library/${library.id}/removeOffline`) .set('Authorization', `Bearer ${admin.accessToken}`) .send(); - expect(status).toBe(201); + expect(status).toBe(204); const assetsAfter = await api.assetApi.getAllAssets(server, admin.accessToken); diff --git a/server/src/immich/controllers/library.controller.ts b/server/src/immich/controllers/library.controller.ts index fb68b2626f..9ad7119799 100644 --- a/server/src/immich/controllers/library.controller.ts +++ b/server/src/immich/controllers/library.controller.ts @@ -10,7 +10,7 @@ import { ValidateLibraryDto, ValidateLibraryResponseDto, } from '@app/domain'; -import { Body, Controller, Delete, Get, HttpCode, Param, Post, Put, Query } from '@nestjs/common'; +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { AdminRoute, Auth, Authenticated } from '../app.guard'; import { UseValidation } from '../app.utils'; @@ -55,6 +55,7 @@ export class LibraryController { } @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) deleteLibrary(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.delete(auth, id); } @@ -65,11 +66,13 @@ export class LibraryController { } @Post(':id/scan') + @HttpCode(HttpStatus.NO_CONTENT) scanLibrary(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @Body() dto: ScanLibraryDto) { return this.service.queueScan(auth, id, dto); } @Post(':id/removeOffline') + @HttpCode(HttpStatus.NO_CONTENT) removeOfflineFiles(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto) { return this.service.queueRemoveOffline(auth, id); }