feat(server,web): libraries (#3124)

* feat: libraries

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
Jonathan Jogenfors
2023-09-20 13:16:33 +02:00
committed by GitHub
parent 816db700e1
commit acdc66413c
143 changed files with 10941 additions and 386 deletions

View File

@@ -106,16 +106,16 @@ describe(`${AlbumController.name} (e2e)`, () => {
const { status, body } = await request(server)
.get('/album?shared=invalid')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest);
expect(status).toEqual(400);
expect(body).toEqual(errorStub.badRequest(['shared must be a boolean value']));
});
it('should reject an invalid assetId param', async () => {
const { status, body } = await request(server)
.get('/album?assetId=invalid')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest);
expect(status).toEqual(400);
expect(body).toEqual(errorStub.badRequest(['assetId must be a UUID']));
});
it('should not return shared albums with a deleted owner', async () => {
@@ -413,7 +413,7 @@ describe(`${AlbumController.name} (e2e)`, () => {
.send({ sharedUserIds: [user1.userId] });
expect(status).toBe(400);
expect(body).toEqual({ ...errorStub.badRequest, message: 'Cannot be shared with owner' });
expect(body).toEqual(errorStub.badRequest('Cannot be shared with owner'));
});
it('should not be able to add existing user to shared album', async () => {
@@ -428,7 +428,7 @@ describe(`${AlbumController.name} (e2e)`, () => {
.send({ sharedUserIds: [user2.userId] });
expect(status).toBe(400);
expect(body).toEqual({ ...errorStub.badRequest, message: 'User already added' });
expect(body).toEqual(errorStub.badRequest('User already added'));
});
});
});

View File

@@ -45,6 +45,7 @@ let assetCount = 0;
const createAsset = (
repository: IAssetRepository,
loginResponse: LoginResponseDto,
libraryId: string,
createdAt: Date,
): Promise<AssetEntity> => {
const id = assetCount++;
@@ -54,6 +55,7 @@ const createAsset = (
originalPath: `/tests/test_${id}`,
deviceAssetId: `test_${id}`,
deviceId: 'e2e-test',
libraryId,
fileCreatedAt: createdAt,
fileModifiedAt: new Date(),
type: AssetType.IMAGE,
@@ -87,15 +89,19 @@ describe(`${AssetController.name} (e2e)`, () => {
await api.authApi.adminSignUp(server);
const admin = await api.authApi.adminLogin(server);
const libraries = await api.libraryApi.getAll(server, admin.accessToken);
const defaultLibrary = libraries[0];
await api.userApi.create(server, admin.accessToken, user1Dto);
user1 = await api.authApi.login(server, { email: user1Dto.email, password: user1Dto.password });
asset1 = await createAsset(assetRepository, user1, new Date('1970-01-01'));
asset2 = await createAsset(assetRepository, user1, new Date('1970-01-02'));
asset3 = await createAsset(assetRepository, user1, new Date('1970-02-01'));
asset1 = await createAsset(assetRepository, user1, defaultLibrary.id, new Date('1970-01-01'));
asset2 = await createAsset(assetRepository, user1, defaultLibrary.id, new Date('1970-01-02'));
asset3 = await createAsset(assetRepository, user1, defaultLibrary.id, new Date('1970-02-01'));
await api.userApi.create(server, admin.accessToken, user2Dto);
user2 = await api.authApi.login(server, { email: user2Dto.email, password: user2Dto.password });
asset4 = await createAsset(assetRepository, user2, new Date('1970-01-01'));
asset4 = await createAsset(assetRepository, user2, defaultLibrary.id, new Date('1970-01-01'));
});
afterAll(async () => {
@@ -139,7 +145,7 @@ describe(`${AssetController.name} (e2e)`, () => {
.attach('assetData', randomBytes(32), 'example.jpg')
.field(dto);
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest);
expect(body).toEqual(errorStub.badRequest());
});
}
@@ -192,7 +198,7 @@ describe(`${AssetController.name} (e2e)`, () => {
.put(`/asset/${uuidStub.invalid}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest);
expect(body).toEqual(errorStub.badRequest(['id must be a UUID']));
});
it('should require access', async () => {

View File

@@ -52,18 +52,33 @@ describe(`${AuthController.name} (e2e)`, () => {
});
const invalid = [
{ should: 'require an email address', data: { firstName, lastName, password } },
{ should: 'require a password', data: { firstName, lastName, email } },
{ should: 'require a first name ', data: { lastName, email, password } },
{ should: 'require a last name ', data: { firstName, email, password } },
{ should: 'require a valid email', data: { firstName, lastName, email: 'immich', password } },
{
should: 'require an email address',
data: { firstName, lastName, password },
},
{
should: 'require a password',
data: { firstName, lastName, email },
},
{
should: 'require a first name ',
data: { lastName, email, password },
},
{
should: 'require a last name ',
data: { firstName, email, password },
},
{
should: 'require a valid email',
data: { firstName, lastName, email: 'immich', password },
},
];
for (const { should, data } of invalid) {
it(`should ${should}`, async () => {
const { status, body } = await request(server).post('/auth/admin-sign-up').send(data);
expect(status).toEqual(400);
expect(body).toEqual(errorStub.badRequest);
expect(body).toEqual(errorStub.badRequest());
});
}
@@ -102,7 +117,7 @@ describe(`${AuthController.name} (e2e)`, () => {
.post('/auth/admin-sign-up')
.send({ ...adminSignupStub, [key]: null });
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest);
expect(body).toEqual(errorStub.badRequest());
});
}
});
@@ -120,7 +135,7 @@ describe(`${AuthController.name} (e2e)`, () => {
.post('/auth/login')
.send({ ...loginStub.admin, [key]: null });
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest);
expect(body).toEqual(errorStub.badRequest());
});
}
@@ -225,7 +240,7 @@ describe(`${AuthController.name} (e2e)`, () => {
.send({ ...changePasswordStub, [key]: null })
.set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest);
expect(body).toEqual(errorStub.badRequest());
});
}

View File

@@ -0,0 +1,494 @@
import { LoginResponseDto } from '@app/domain';
import { AppModule, LibraryController } from '@app/immich';
import { LibraryType } from '@app/infra/entities';
import { INestApplication } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import request from 'supertest';
import { errorStub, userStub, uuidStub } from '../fixtures';
import { api, db } from '../test-utils';
describe(`${LibraryController.name} (e2e)`, () => {
let app: INestApplication;
let server: any;
let loginResponse: LoginResponseDto;
let accessToken: string;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = await moduleFixture.createNestApplication().init();
server = app.getHttpServer();
});
beforeEach(async () => {
await db.reset();
await api.adminSignUp(server);
loginResponse = await api.adminLogin(server);
accessToken = loginResponse.accessToken;
});
afterAll(async () => {
await db.disconnect();
await app.close();
});
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 ${accessToken}`);
expect(status).toBe(200);
expect(body).toHaveLength(1);
expect(body).toEqual([
{
id: expect.any(String),
ownerId: loginResponse.userId,
type: LibraryType.UPLOAD,
name: 'Default Library',
createdAt: expect.any(String),
updatedAt: expect.any(String),
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 ${accessToken}`)
.send({ type: LibraryType.EXTERNAL });
expect(status).toBe(201);
expect(body).toEqual({
id: expect.any(String),
ownerId: loginResponse.userId,
type: LibraryType.EXTERNAL,
name: 'New External Library',
createdAt: expect.any(String),
updatedAt: expect.any(String),
refreshedAt: null,
assetCount: 0,
importPaths: [],
exclusionPatterns: [],
});
});
it('with name', async () => {
const { status, body } = await request(server)
.post('/library')
.set('Authorization', `Bearer ${accessToken}`)
.send({ type: LibraryType.EXTERNAL, name: 'My Awesome Library' });
expect(status).toBe(201);
expect(body).toEqual({
id: expect.any(String),
ownerId: loginResponse.userId,
type: LibraryType.EXTERNAL,
name: 'My Awesome Library',
createdAt: expect.any(String),
updatedAt: expect.any(String),
refreshedAt: null,
assetCount: 0,
importPaths: [],
exclusionPatterns: [],
});
});
it('with import paths', async () => {
const { status, body } = await request(server)
.post('/library')
.set('Authorization', `Bearer ${accessToken}`)
.send({ type: LibraryType.EXTERNAL, importPaths: ['/path/to/import'] });
expect(status).toBe(201);
expect(body).toEqual({
id: expect.any(String),
ownerId: loginResponse.userId,
type: LibraryType.EXTERNAL,
name: 'New External Library',
createdAt: expect.any(String),
updatedAt: expect.any(String),
refreshedAt: null,
assetCount: 0,
importPaths: ['/path/to/import'],
exclusionPatterns: [],
});
});
it('with exclusion patterns', async () => {
const { status, body } = await request(server)
.post('/library')
.set('Authorization', `Bearer ${accessToken}`)
.send({ type: LibraryType.EXTERNAL, exclusionPatterns: ['**/Raw/**'] });
expect(status).toBe(201);
expect(body).toEqual({
id: expect.any(String),
ownerId: loginResponse.userId,
type: LibraryType.EXTERNAL,
name: 'New External Library',
createdAt: expect.any(String),
updatedAt: expect.any(String),
refreshedAt: null,
assetCount: 0,
importPaths: [],
exclusionPatterns: ['**/Raw/**'],
});
});
});
describe('upload library', () => {
it('with default settings', async () => {
const { status, body } = await request(server)
.post('/library')
.set('Authorization', `Bearer ${accessToken}`)
.send({ type: LibraryType.UPLOAD });
expect(status).toBe(201);
expect(body).toEqual({
id: expect.any(String),
ownerId: loginResponse.userId,
type: LibraryType.UPLOAD,
name: 'New Upload Library',
createdAt: expect.any(String),
updatedAt: expect.any(String),
refreshedAt: null,
assetCount: 0,
importPaths: [],
exclusionPatterns: [],
});
});
it('with name', async () => {
const { status, body } = await request(server)
.post('/library')
.set('Authorization', `Bearer ${accessToken}`)
.send({ type: LibraryType.UPLOAD, name: 'My Awesome Library' });
expect(status).toBe(201);
expect(body).toEqual({
id: expect.any(String),
ownerId: loginResponse.userId,
type: LibraryType.UPLOAD,
name: 'My Awesome Library',
createdAt: expect.any(String),
updatedAt: expect.any(String),
refreshedAt: null,
assetCount: 0,
importPaths: [],
exclusionPatterns: [],
});
});
it('with import paths should fail', async () => {
const { status, body } = await request(server)
.post('/library')
.set('Authorization', `Bearer ${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 ${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.userCreate(server, accessToken, userStub.user1);
const loginResponse = await api.login(server, {
email: userStub.user1.email,
password: userStub.user1.password ?? '',
});
const { status, body } = await request(server)
.post('/library')
.set('Authorization', `Bearer ${loginResponse.accessToken}`)
.send({ type: LibraryType.EXTERNAL });
expect(status).toBe(201);
expect(body).toEqual({
id: expect.any(String),
ownerId: loginResponse.userId,
type: LibraryType.EXTERNAL,
name: 'New External Library',
createdAt: expect.any(String),
updatedAt: expect.any(String),
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 libraryId: string;
beforeEach(async () => {
// Create an external library with default settings
const { status, body } = await request(server)
.post('/library')
.set('Authorization', `Bearer ${accessToken}`)
.send({ type: LibraryType.EXTERNAL });
expect(status).toBe(201);
libraryId = body.id;
});
it('should change the library name', async () => {
const { status, body } = await request(server)
.put(`/library/${libraryId}`)
.set('Authorization', `Bearer ${accessToken}`)
.send({ name: 'New Library Name' });
expect(status).toBe(200);
expect(body).toEqual({
id: expect.any(String),
ownerId: loginResponse.userId,
type: LibraryType.EXTERNAL,
name: 'New Library Name',
createdAt: expect.any(String),
updatedAt: expect.any(String),
refreshedAt: null,
assetCount: 0,
importPaths: [],
exclusionPatterns: [],
});
});
it('should not set an empty name', async () => {
const { status, body } = await request(server)
.put(`/library/${libraryId}`)
.set('Authorization', `Bearer ${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/${libraryId}`)
.set('Authorization', `Bearer ${accessToken}`)
.send({ importPaths: ['/path/to/import'] });
expect(status).toBe(200);
expect(body).toEqual({
id: expect.any(String),
ownerId: loginResponse.userId,
type: LibraryType.EXTERNAL,
name: 'New External Library',
createdAt: expect.any(String),
updatedAt: expect.any(String),
refreshedAt: null,
assetCount: 0,
importPaths: ['/path/to/import'],
exclusionPatterns: [],
});
});
it('should not allow an empty import path', async () => {
const { status, body } = await request(server)
.put(`/library/${libraryId}`)
.set('Authorization', `Bearer ${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/${libraryId}`)
.set('Authorization', `Bearer ${accessToken}`)
.send({ exclusionPatterns: [''] });
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest(['each value in exclusionPatterns should not be empty']));
});
it('should not allow an empty exclusion pattern', async () => {
const { status, body } = await request(server)
.put(`/library/${libraryId}`)
.set('Authorization', `Bearer ${accessToken}`)
.send({ importPaths: [''] });
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest(['each value in importPaths 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 () => {
let libraryId: string;
{
const { status, body } = await request(server)
.post('/library')
.set('Authorization', `Bearer ${accessToken}`)
.send({ type: LibraryType.EXTERNAL });
expect(status).toBe(201);
libraryId = body.id;
}
const { status, body } = await request(server)
.get(`/library/${libraryId}`)
.set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({
id: expect.any(String),
ownerId: loginResponse.userId,
type: LibraryType.EXTERNAL,
name: 'New External Library',
createdAt: expect.any(String),
updatedAt: expect.any(String),
refreshedAt: null,
assetCount: 0,
importPaths: [],
exclusionPatterns: [],
});
});
it("should not allow getting another user's library", async () => {
await api.userCreate(server, accessToken, userStub.user1);
const loginResponse = await api.login(server, {
email: userStub.user1.email,
password: userStub.user1.password ?? '',
});
let libraryId: string;
{
const { status, body } = await request(server)
.post('/library')
.set('Authorization', `Bearer ${accessToken}`)
.send({ type: LibraryType.EXTERNAL });
expect(status).toBe(201);
libraryId = body.id;
}
const { status, body } = await request(server)
.get(`/library/${libraryId}`)
.set('Authorization', `Bearer ${loginResponse.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, accessToken);
expect(defaultLibrary).toBeDefined();
const { status, body } = await request(server)
.delete(`/library/${defaultLibrary.id}`)
.set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorStub.noDeleteUploadLibrary);
});
});
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);
});
it('should scan external library', async () => {
let libraryId: string;
{
const { status, body } = await request(server)
.post('/library')
.set('Authorization', `Bearer ${accessToken}`)
.send({ type: LibraryType.EXTERNAL });
expect(status).toBe(201);
libraryId = body.id;
}
const { status, body } = await request(server)
.post(`/library/${libraryId}/scan`)
.set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(201);
expect(body).toEqual({});
});
it('should not scan an upload library', async () => {
let libraryId: string;
{
const { status, body } = await request(server)
.post('/library')
.set('Authorization', `Bearer ${accessToken}`)
.send({ type: LibraryType.UPLOAD });
expect(status).toBe(201);
libraryId = body.id;
}
const { status, body } = await request(server)
.post(`/library/${libraryId}/scan`)
.set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest('Can only refresh external libraries'));
});
});
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);
});
});
});

View File

@@ -37,7 +37,7 @@ describe(`${OAuthController.name} (e2e)`, () => {
it(`should throw an error if a redirect uri is not provided`, async () => {
const { status, body } = await request(server).post('/oauth/authorize').send({});
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest);
expect(body).toEqual(errorStub.badRequest(['redirectUri must be a string', 'redirectUri should not be empty']));
});
});
});

View File

@@ -110,7 +110,7 @@ describe(`${PersonController.name}`, () => {
.set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest);
expect(body).toEqual(errorStub.badRequest());
});
it('should return person information', async () => {
@@ -130,25 +130,34 @@ describe(`${PersonController.name}`, () => {
expect(body).toEqual(errorStub.unauthorized);
});
for (const key of ['name', 'featureFaceAssetId', 'isHidden']) {
for (const { key, type } of [
{ key: 'name', type: 'string' },
{ key: 'featureFaceAssetId', type: 'string' },
{ key: 'isHidden', type: 'boolean value' },
]) {
it(`should not allow null ${key}`, async () => {
const { status, body } = await request(server)
.put(`/person/${visiblePerson.id}`)
.set('Authorization', `Bearer ${accessToken}`)
.send({ [key]: null });
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest);
expect(body).toEqual(errorStub.badRequest([`${key} must be a ${type}`]));
});
}
it('should not accept invalid birth dates', async () => {
for (const birthDate of [false, 'false', '123567', 123456]) {
for (const { birthDate, response } of [
{ birthDate: false, response: ['id must be a UUID'] },
{ birthDate: 'false', response: ['birthDate must be a Date instance'] },
{ birthDate: '123567', response: ['id must be a UUID'] },
{ birthDate: 123456, response: ['id must be a UUID'] },
]) {
const { status, body } = await request(server)
.put(`/person/${uuidStub.notFound}`)
.set('Authorization', `Bearer ${accessToken}`)
.send({ birthDate });
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest);
expect(body).toEqual(errorStub.badRequest(response));
}
});

View File

@@ -160,7 +160,7 @@ describe(`${PartnerController.name} (e2e)`, () => {
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest);
expect(body).toEqual(errorStub.badRequest());
});
it('should require an asset/album id', async () => {
@@ -211,7 +211,7 @@ describe(`${PartnerController.name} (e2e)`, () => {
.send({ description: 'foo' });
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest);
expect(body).toEqual(errorStub.badRequest());
});
it('should update shared link', async () => {
@@ -241,7 +241,7 @@ describe(`${PartnerController.name} (e2e)`, () => {
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest);
expect(body).toEqual(errorStub.badRequest());
});
it('should update shared link', async () => {

View File

@@ -138,7 +138,7 @@ describe(`${UserController.name}`, () => {
.set('Authorization', `Bearer ${accessToken}`)
.send({ ...userSignupStub, [key]: null });
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest);
expect(body).toEqual(errorStub.badRequest());
});
}
@@ -238,7 +238,7 @@ describe(`${UserController.name}`, () => {
.set('Authorization', `Bearer ${accessToken}`)
.send({ ...userStub.admin, [key]: null });
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest);
expect(body).toEqual(errorStub.badRequest());
});
}