refactor: library controller tests (#30863)

This commit is contained in:
Jason Rasmussen 2026-08-19 09:54:08 -04:00 committed by GitHub
parent 2018e4f8c4
commit db3b31709d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 203 additions and 173 deletions

View file

@ -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 });

View file

@ -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();
});
});
});