diff --git a/e2e/src/specs/server/api/library.e2e-spec.ts b/e2e/src/specs/server/api/library.e2e-spec.ts index 47a6da0648..b508a52f3a 100644 --- a/e2e/src/specs/server/api/library.e2e-spec.ts +++ b/e2e/src/specs/server/api/library.e2e-spec.ts @@ -1,8 +1,6 @@ import { LibraryResponseDto, LoginResponseDto, getAllLibraries } from '@immich/sdk'; import { cpSync, existsSync } from 'node:fs'; import { Socket } from 'socket.io-client'; -import { userDto, uuidDto } from 'src/fixtures'; -import { errorDto } from 'src/responses'; import { app, asBearerAuth, testAssetDir, testAssetDirInternal, utils } from 'src/utils'; import request from 'supertest'; import { utimes } from 'utimes'; @@ -10,7 +8,6 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; describe('/libraries', () => { let admin: LoginResponseDto; - let user: LoginResponseDto; let library: LibraryResponseDto; let websocket: Socket; @@ -18,7 +15,6 @@ describe('/libraries', () => { await utils.resetDatabase(); admin = await utils.adminSetup(); await utils.resetAdminConfig(admin.accessToken); - user = await utils.userSetup(admin.accessToken, userDto.user1); library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId }); websocket = await utils.connectWebsocket(admin.accessToken); utils.createImageFile(`${testAssetDir}/temp/directoryA/assetA.png`); @@ -34,31 +30,7 @@ describe('/libraries', () => { utils.resetEvents(); }); - describe('GET /libraries', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).get('/libraries'); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - }); - describe('POST /libraries', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).post('/libraries').send({}); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - - it('should require admin authentication', async () => { - const { status, body } = await request(app) - .post('/libraries') - .set('Authorization', `Bearer ${user.accessToken}`) - .send({ ownerId: admin.userId }); - - 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('/libraries') @@ -97,49 +69,9 @@ describe('/libraries', () => { }), ); }); - - it('should not create an external library with duplicate import paths', async () => { - const { status, body } = await request(app) - .post('/libraries') - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ - ownerId: admin.userId, - name: 'My Awesome Library', - importPaths: ['/path', '/path'], - exclusionPatterns: ['**/Raw/**'], - }); - - expect(status).toBe(400); - expect(body).toEqual( - errorDto.validationError([{ path: ['importPaths'], message: 'Array must have unique items' }]), - ); - }); - - it('should not create an external library with duplicate exclusion patterns', async () => { - const { status, body } = await request(app) - .post('/libraries') - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ - ownerId: admin.userId, - name: 'My Awesome Library', - importPaths: ['/path/to/import'], - exclusionPatterns: ['**/Raw/**', '**/Raw/**'], - }); - - expect(status).toBe(400); - expect(body).toEqual( - errorDto.validationError([{ path: ['exclusionPatterns'], message: 'Array must have unique items' }]), - ); - }); }); describe('PUT /libraries/:id', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).put(`/libraries/${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(`/libraries/${library.id}`) @@ -154,18 +86,6 @@ describe('/libraries', () => { ); }); - it('should not set an empty name', async () => { - const { status, body } = await request(app) - .put(`/libraries/${library.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ name: '' }); - - expect(status).toBe(400); - expect(body).toEqual( - errorDto.validationError([{ path: ['name'], message: 'Too small: expected string to have >=1 characters' }]), - ); - }); - it('should change the import paths', async () => { const { status, body } = await request(app) .put(`/libraries/${library.id}`) @@ -180,30 +100,6 @@ describe('/libraries', () => { ); }); - it('should reject an empty import path', async () => { - const { status, body } = await request(app) - .put(`/libraries/${library.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ importPaths: [''] }); - - expect(status).toBe(400); - expect(body).toEqual( - errorDto.validationError([{ path: ['importPaths'], message: 'Array items must not be empty' }]), - ); - }); - - it('should reject duplicate import paths', async () => { - const { status, body } = await request(app) - .put(`/libraries/${library.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ importPaths: ['/path', '/path'] }); - - expect(status).toBe(400); - expect(body).toEqual( - errorDto.validationError([{ path: ['importPaths'], message: 'Array must have unique items' }]), - ); - }); - it('should change the exclusion pattern', async () => { const { status, body } = await request(app) .put(`/libraries/${library.id}`) @@ -217,48 +113,9 @@ describe('/libraries', () => { }), ); }); - - it('should reject duplicate exclusion patterns', async () => { - const { status, body } = await request(app) - .put(`/libraries/${library.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ exclusionPatterns: ['**/*.jpg', '**/*.jpg'] }); - - expect(status).toBe(400); - expect(body).toEqual( - errorDto.validationError([{ path: ['exclusionPatterns'], message: 'Array must have unique items' }]), - ); - }); - - it('should reject an empty exclusion pattern', async () => { - const { status, body } = await request(app) - .put(`/libraries/${library.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ exclusionPatterns: [''] }); - - expect(status).toBe(400); - expect(body).toEqual( - errorDto.validationError([{ path: ['exclusionPatterns'], message: 'Array items must not be empty' }]), - ); - }); }); describe('GET /libraries/:id', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).get(`/libraries/${uuidDto.notFound}`); - - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - - it('should require admin access', async () => { - const { status, body } = await request(app) - .get(`/libraries/${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 utils.createLibrary(admin.accessToken, { ownerId: admin.userId }); @@ -280,23 +137,7 @@ describe('/libraries', () => { }); }); - describe('GET /libraries/:id/statistics', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).get(`/libraries/${uuidDto.notFound}/statistics`); - - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - }); - describe('POST /libraries/:id/scan', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).post(`/libraries/${uuidDto.notFound}/scan`).send({}); - - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - it('should import new asset when scanning external library', async () => { const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, @@ -785,13 +626,6 @@ describe('/libraries', () => { }); describe('POST /libraries/:id/validate', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).post(`/libraries/${uuidDto.notFound}/validate`).send({}); - - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - it('should pass with no import paths', async () => { const response = await utils.validateLibrary(admin.accessToken, library.id, { importPaths: [] }); expect(response.importPaths).toEqual([]); @@ -856,13 +690,6 @@ describe('/libraries', () => { }); describe('DELETE /libraries/:id', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).delete(`/libraries/${uuidDto.notFound}`); - - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - it('should delete an external library', async () => { const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId }); diff --git a/server/src/controllers/library.controller.spec.ts b/server/src/controllers/library.controller.spec.ts new file mode 100644 index 0000000000..c6f1b21ccc --- /dev/null +++ b/server/src/controllers/library.controller.spec.ts @@ -0,0 +1,203 @@ +import { LibraryController } from 'src/controllers/library.controller'; +import { LibraryService } from 'src/services/library.service'; +import request from 'supertest'; +import { factory } from 'test/small.factory'; +import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils'; + +describe(LibraryController.name, () => { + let ctx: ControllerContext; + const service = mockBaseService(LibraryService); + + beforeAll(async () => { + ctx = await controllerSetup(LibraryController, [{ provide: LibraryService, useValue: service }]); + return () => ctx.close(); + }); + + beforeEach(() => { + service.resetAllMocks(); + ctx.reset(); + }); + + const id = factory.uuid(); + + describe('authentication', () => { + const routes = [ + { method: 'get', path: '/libraries' }, + { method: 'post', path: '/libraries' }, + { method: 'get', path: `/libraries/${id}` }, + { method: 'put', path: `/libraries/${id}` }, + { method: 'patch', path: `/libraries/${id}` }, + { method: 'delete', path: `/libraries/${id}` }, + { method: 'post', path: `/libraries/${id}/validate` }, + { method: 'get', path: `/libraries/${id}/statistics` }, + { method: 'post', path: `/libraries/${id}/scan` }, + ] as const; + + it.each(routes)('$method $path should be an admin route', async ({ method, path }) => { + await request(ctx.getHttpServer())[method](path).send({}); + + expect(ctx.authenticate).toHaveBeenCalledWith( + expect.objectContaining({ metadata: expect.objectContaining({ adminRoute: true }) }), + ); + }); + }); + + describe('POST /libraries', () => { + it('should require an owner id', async () => { + const { status, body } = await request(ctx.getHttpServer()).post('/libraries').send({}); + + expect(status).toBe(400); + expect(body).toEqual( + factory.responses.validationError([ + { path: ['ownerId'], message: 'Invalid input: expected string, received undefined' }, + ]), + ); + expect(service.create).not.toHaveBeenCalled(); + }); + + it('should reject duplicate import paths', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .post('/libraries') + .send({ ownerId: id, importPaths: ['/path', '/path'] }); + + expect(status).toBe(400); + expect(body).toEqual( + factory.responses.validationError([{ path: ['importPaths'], message: 'Array must have unique items' }]), + ); + expect(service.create).not.toHaveBeenCalled(); + }); + + it('should reject duplicate exclusion patterns', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .post('/libraries') + .send({ ownerId: id, exclusionPatterns: ['**/Raw/**', '**/Raw/**'] }); + + expect(status).toBe(400); + expect(body).toEqual( + factory.responses.validationError([{ path: ['exclusionPatterns'], message: 'Array must have unique items' }]), + ); + expect(service.create).not.toHaveBeenCalled(); + }); + }); + + describe('PUT /libraries/:id', () => { + it('should require a valid uuid', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .put('/libraries/invalid') + .send({ name: 'New Library Name' }); + + expect(status).toBe(400); + expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }])); + expect(service.update).not.toHaveBeenCalled(); + }); + + it('should reject an empty name', async () => { + const { status, body } = await request(ctx.getHttpServer()).put(`/libraries/${id}`).send({ name: '' }); + + expect(status).toBe(400); + expect(body).toEqual( + factory.responses.validationError([ + { path: ['name'], message: 'Too small: expected string to have >=1 characters' }, + ]), + ); + expect(service.update).not.toHaveBeenCalled(); + }); + + it('should reject an empty import path', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .put(`/libraries/${id}`) + .send({ importPaths: [''] }); + + expect(status).toBe(400); + expect(body).toEqual( + factory.responses.validationError([{ path: ['importPaths'], message: 'Array items must not be empty' }]), + ); + expect(service.update).not.toHaveBeenCalled(); + }); + + it('should reject duplicate import paths', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .put(`/libraries/${id}`) + .send({ importPaths: ['/path', '/path'] }); + + expect(status).toBe(400); + expect(body).toEqual( + factory.responses.validationError([{ path: ['importPaths'], message: 'Array must have unique items' }]), + ); + expect(service.update).not.toHaveBeenCalled(); + }); + + it('should reject an empty exclusion pattern', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .put(`/libraries/${id}`) + .send({ exclusionPatterns: [''] }); + + expect(status).toBe(400); + expect(body).toEqual( + factory.responses.validationError([{ path: ['exclusionPatterns'], message: 'Array items must not be empty' }]), + ); + expect(service.update).not.toHaveBeenCalled(); + }); + + it('should reject duplicate exclusion patterns', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .put(`/libraries/${id}`) + .send({ exclusionPatterns: ['**/*.jpg', '**/*.jpg'] }); + + expect(status).toBe(400); + expect(body).toEqual( + factory.responses.validationError([{ path: ['exclusionPatterns'], message: 'Array must have unique items' }]), + ); + expect(service.update).not.toHaveBeenCalled(); + }); + }); + + describe('POST /libraries/:id/validate', () => { + it('should require a valid uuid', async () => { + const { status, body } = await request(ctx.getHttpServer()).post('/libraries/invalid/validate').send({}); + + expect(status).toBe(400); + expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }])); + expect(service.validate).not.toHaveBeenCalled(); + }); + + it('should not require import paths', async () => { + service.validate.mockResolvedValue({ importPaths: [] }); + + const { status } = await request(ctx.getHttpServer()).post(`/libraries/${id}/validate`).send({}); + + expect(status).toBe(200); + expect(service.validate).toHaveBeenCalledWith(id, {}); + }); + }); + + describe('GET /libraries/:id/statistics', () => { + it('should require a valid uuid', async () => { + const { status, body } = await request(ctx.getHttpServer()).get('/libraries/invalid/statistics'); + + expect(status).toBe(400); + expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }])); + expect(service.getStatistics).not.toHaveBeenCalled(); + }); + }); + + describe('POST /libraries/:id/scan', () => { + it('should require a valid uuid', async () => { + const { status, body } = await request(ctx.getHttpServer()).post('/libraries/invalid/scan'); + + expect(status).toBe(400); + expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }])); + expect(service.queueScan).not.toHaveBeenCalled(); + }); + }); + + describe('DELETE /libraries/:id', () => { + it('should require a valid uuid', async () => { + const { status, body } = await request(ctx.getHttpServer()).delete('/libraries/invalid'); + + expect(status).toBe(400); + expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }])); + expect(service.delete).not.toHaveBeenCalled(); + }); + }); +});