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

@@ -1,12 +1,14 @@
import { albumApi } from './album-api';
import { assetApi } from './asset-api';
import { authApi } from './auth-api';
import { libraryApi } from './library-api';
import { sharedLinkApi } from './shared-link-api';
import { userApi } from './user-api';
export const api = {
authApi,
assetApi,
libraryApi,
sharedLinkApi,
albumApi,
userApi,

View File

@@ -0,0 +1,10 @@
import { LibraryResponseDto } 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[];
},
};

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

View File

@@ -1,6 +1,7 @@
import { AssetEntity, AssetType, ExifEntity } from '@app/infra/entities';
import { authStub } from './auth.stub';
import { fileStub } from './file.stub';
import { libraryStub } from './library.stub';
import { userStub } from './user.stub';
export const assetStub = {
@@ -33,6 +34,10 @@ export const assetStub = {
faces: [],
sidecarPath: null,
isReadOnly: false,
isOffline: false,
isExternal: false,
libraryId: 'library-id',
library: libraryStub.uploadLibrary1,
}),
noWebpPath: Object.freeze<AssetEntity>({
id: 'asset-id',
@@ -63,6 +68,10 @@ export const assetStub = {
faces: [],
sidecarPath: null,
isReadOnly: false,
isOffline: false,
isExternal: false,
libraryId: 'library-id',
library: libraryStub.uploadLibrary1,
exifInfo: {
fileSizeInByte: 123_000,
} as ExifEntity,
@@ -87,8 +96,12 @@ export const assetStub = {
isFavorite: true,
isArchived: false,
isReadOnly: false,
isOffline: false,
libraryId: 'library-id',
library: libraryStub.uploadLibrary1,
duration: null,
isVisible: true,
isExternal: false,
livePhotoVideo: null,
livePhotoVideoId: null,
tags: [],
@@ -119,8 +132,86 @@ export const assetStub = {
isReadOnly: false,
duration: null,
isVisible: true,
isExternal: false,
livePhotoVideo: null,
livePhotoVideoId: null,
isOffline: false,
libraryId: 'library-id',
library: libraryStub.uploadLibrary1,
tags: [],
sharedLinks: [],
originalFileName: 'asset-id.jpg',
faces: [],
sidecarPath: null,
exifInfo: {
fileSizeInByte: 5_000,
} as ExifEntity,
}),
external: Object.freeze<AssetEntity>({
id: 'asset-id',
deviceAssetId: 'device-asset-id',
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
owner: userStub.user1,
ownerId: 'user-id',
deviceId: 'device-id',
originalPath: '/data/user1/photo.jpg',
resizePath: '/uploads/user-id/thumbs/path.jpg',
checksum: Buffer.from('file hash', 'utf8'),
type: AssetType.IMAGE,
webpPath: '/uploads/user-id/webp/path.ext',
thumbhash: Buffer.from('blablabla', 'base64'),
encodedVideoPath: null,
createdAt: new Date('2023-02-23T05:06:29.716Z'),
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
isFavorite: true,
isArchived: false,
isReadOnly: false,
isExternal: true,
duration: null,
isVisible: true,
livePhotoVideo: null,
livePhotoVideoId: null,
isOffline: false,
libraryId: 'library-id',
library: libraryStub.uploadLibrary1,
tags: [],
sharedLinks: [],
originalFileName: 'asset-id.jpg',
faces: [],
sidecarPath: null,
exifInfo: {
fileSizeInByte: 5_000,
} as ExifEntity,
}),
offline: Object.freeze<AssetEntity>({
id: 'asset-id',
deviceAssetId: 'device-asset-id',
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
owner: userStub.user1,
ownerId: 'user-id',
deviceId: 'device-id',
originalPath: '/original/path.jpg',
resizePath: '/uploads/user-id/thumbs/path.jpg',
checksum: Buffer.from('file hash', 'utf8'),
type: AssetType.IMAGE,
webpPath: '/uploads/user-id/webp/path.ext',
thumbhash: Buffer.from('blablabla', 'base64'),
encodedVideoPath: null,
createdAt: new Date('2023-02-23T05:06:29.716Z'),
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
isFavorite: true,
isArchived: false,
isReadOnly: false,
isExternal: false,
duration: null,
isVisible: true,
livePhotoVideo: null,
livePhotoVideoId: null,
isOffline: true,
libraryId: 'library-id',
library: libraryStub.uploadLibrary1,
tags: [],
sharedLinks: [],
originalFileName: 'asset-id.jpg',
@@ -149,7 +240,11 @@ export const assetStub = {
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
isFavorite: true,
isArchived: false,
isExternal: false,
isReadOnly: false,
isOffline: false,
libraryId: 'library-id',
library: libraryStub.uploadLibrary1,
duration: null,
isVisible: true,
livePhotoVideo: null,
@@ -184,6 +279,10 @@ export const assetStub = {
isFavorite: true,
isArchived: false,
isReadOnly: false,
isExternal: false,
isOffline: false,
libraryId: 'library-id',
library: libraryStub.uploadLibrary1,
duration: null,
isVisible: true,
livePhotoVideo: null,
@@ -204,6 +303,8 @@ export const assetStub = {
isVisible: false,
fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'),
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
libraryId: 'library-id',
library: libraryStub.uploadLibrary1,
exifInfo: {
fileSizeInByte: 100_000,
},
@@ -218,6 +319,8 @@ export const assetStub = {
isVisible: true,
fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'),
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
libraryId: 'library-id',
library: libraryStub.uploadLibrary1,
exifInfo: {
fileSizeInByte: 25_000,
},
@@ -244,6 +347,10 @@ export const assetStub = {
isFavorite: false,
isArchived: false,
isReadOnly: false,
isExternal: false,
isOffline: false,
libraryId: 'library-id',
library: libraryStub.uploadLibrary1,
duration: null,
isVisible: true,
livePhotoVideo: null,
@@ -278,6 +385,10 @@ export const assetStub = {
isFavorite: true,
isArchived: false,
isReadOnly: false,
isExternal: false,
isOffline: false,
libraryId: 'library-id',
library: libraryStub.uploadLibrary1,
duration: null,
isVisible: true,
livePhotoVideo: null,

View File

@@ -24,11 +24,11 @@ export const errorStub = {
statusCode: 401,
message: 'Invalid share key',
},
badRequest: {
badRequest: (message: any = null) => ({
error: 'Bad Request',
statusCode: 400,
message: expect.any(Array),
},
message: message ?? expect.anything(),
}),
noPermission: {
error: 'Bad Request',
statusCode: 400,
@@ -44,4 +44,9 @@ export const errorStub = {
statusCode: 400,
message: 'The server already has an admin',
},
noDeleteUploadLibrary: {
error: 'Bad Request',
statusCode: 400,
message: 'Cannot delete the last upload library',
},
};

View File

@@ -7,6 +7,7 @@ export * from './device.stub';
export * from './error.stub';
export * from './face.stub';
export * from './file.stub';
export * from './library.stub';
export * from './media.stub';
export * from './partner.stub';
export * from './person.stub';

33
server/test/fixtures/library.stub.ts vendored Normal file
View File

@@ -0,0 +1,33 @@
import { LibraryEntity, LibraryType } from '@app/infra/entities';
import { userStub } from './user.stub';
export const libraryStub = {
uploadLibrary1: Object.freeze<LibraryEntity>({
id: 'library-id',
name: 'test_library',
assets: [],
owner: userStub.user1,
ownerId: 'user-id',
type: LibraryType.UPLOAD,
importPaths: [],
createdAt: new Date('2022-01-01'),
updatedAt: new Date('2022-01-01'),
refreshedAt: null,
isVisible: true,
exclusionPatterns: [],
}),
externalLibrary1: Object.freeze<LibraryEntity>({
id: 'library-id',
name: 'test_library',
assets: [],
owner: userStub.externalPath1,
ownerId: 'user-id',
type: LibraryType.EXTERNAL,
importPaths: [],
createdAt: new Date('2023-01-01'),
updatedAt: new Date('2023-01-01'),
refreshedAt: null,
isVisible: true,
exclusionPatterns: [],
}),
};

View File

@@ -2,6 +2,7 @@ import { AlbumResponseDto, AssetResponseDto, ExifResponseDto, mapUser, SharedLin
import { AssetType, SharedLinkEntity, SharedLinkType, UserEntity } from '@app/infra/entities';
import { assetStub } from './asset.stub';
import { authStub } from './auth.stub';
import { libraryStub } from './library.stub';
import { userStub } from './user.stub';
const today = new Date();
@@ -50,6 +51,9 @@ const assetResponse: AssetResponseDto = {
resized: false,
thumbhash: null,
fileModifiedAt: today,
isExternal: false,
isReadOnly: false,
isOffline: false,
fileCreatedAt: today,
updatedAt: today,
isFavorite: false,
@@ -64,6 +68,7 @@ const assetResponse: AssetResponseDto = {
tags: [],
people: [],
checksum: 'ZmlsZSBoYXNo',
libraryId: 'library-id',
};
const albumResponse: AlbumResponseDto = {
@@ -173,7 +178,11 @@ export const sharedLinkStub = {
updatedAt: today,
isFavorite: false,
isArchived: false,
isExternal: false,
isReadOnly: false,
isOffline: false,
libraryId: 'library-id',
library: libraryStub.uploadLibrary1,
smartInfo: {
assetId: 'id_1',
tags: [],

View File

@@ -70,4 +70,38 @@ export const userStub = {
assets: [],
memoriesEnabled: true,
}),
externalPath1: Object.freeze<UserEntity>({
...authStub.user1,
password: 'immich_password',
firstName: 'immich_first_name',
lastName: 'immich_last_name',
storageLabel: 'label-1',
externalPath: '/data/user1',
oauthId: '',
shouldChangePassword: false,
profileImagePath: '',
createdAt: new Date('2021-01-01'),
deletedAt: null,
updatedAt: new Date('2021-01-01'),
tags: [],
assets: [],
memoriesEnabled: true,
}),
externalPath2: Object.freeze<UserEntity>({
...authStub.user1,
password: 'immich_password',
firstName: 'immich_first_name',
lastName: 'immich_last_name',
storageLabel: 'label-1',
externalPath: '/data/user2',
oauthId: '',
shouldChangePassword: false,
profileImagePath: '',
createdAt: new Date('2021-01-01'),
deletedAt: null,
updatedAt: new Date('2021-01-01'),
tags: [],
assets: [],
memoriesEnabled: true,
}),
};

View File

@@ -4,6 +4,7 @@ export interface IAccessRepositoryMock {
asset: jest.Mocked<IAccessRepository['asset']>;
album: jest.Mocked<IAccessRepository['album']>;
library: jest.Mocked<IAccessRepository['library']>;
timeline: jest.Mocked<IAccessRepository['timeline']>;
person: jest.Mocked<IAccessRepository['person']>;
}
@@ -23,6 +24,11 @@ export const newAccessRepositoryMock = (): IAccessRepositoryMock => {
},
library: {
hasOwnerAccess: jest.fn(),
hasPartnerAccess: jest.fn(),
},
timeline: {
hasPartnerAccess: jest.fn(),
},

View File

@@ -2,6 +2,7 @@ import { IAssetRepository } from '@app/domain';
export const newAssetRepositoryMock = (): jest.Mocked<IAssetRepository> => {
return {
create: jest.fn(),
upsertExif: jest.fn(),
getByDate: jest.fn(),
getByIds: jest.fn().mockResolvedValue([]),
@@ -14,6 +15,9 @@ export const newAssetRepositoryMock = (): jest.Mocked<IAssetRepository> => {
getLastUpdatedAssetForAlbumId: jest.fn(),
getAll: jest.fn().mockResolvedValue({ items: [], hasNextPage: false }),
updateAll: jest.fn(),
getByLibraryId: jest.fn(),
getById: jest.fn(),
getByLibraryIdAndOriginalPath: jest.fn(),
deleteAll: jest.fn(),
save: jest.fn(),
findLivePhotoMatch: jest.fn(),
@@ -21,5 +25,6 @@ export const newAssetRepositoryMock = (): jest.Mocked<IAssetRepository> => {
getStatistics: jest.fn(),
getByTimeBucket: jest.fn(),
getTimeBuckets: jest.fn(),
remove: jest.fn(),
};
};

View File

@@ -7,6 +7,7 @@ export * from './communication.repository.mock';
export * from './crypto.repository.mock';
export * from './face.repository.mock';
export * from './job.repository.mock';
export * from './library.repository.mock';
export * from './machine-learning.repository.mock';
export * from './media.repository.mock';
export * from './partner.repository.mock';

View File

@@ -0,0 +1,21 @@
import { ILibraryRepository } from '@app/domain';
export const newLibraryRepositoryMock = (): jest.Mocked<ILibraryRepository> => {
return {
get: jest.fn(),
getCountForUser: jest.fn(),
getAllByUserId: jest.fn(),
create: jest.fn(),
delete: jest.fn(),
softDelete: jest.fn(),
update: jest.fn(),
getStatistics: jest.fn(),
getDefaultUploadLibrary: jest.fn(),
getUploadLibraryCount: jest.fn(),
getOnlineAssetPaths: jest.fn(),
getAssetIds: jest.fn(),
existsByName: jest.fn(),
getAllDeleted: jest.fn(),
getAll: jest.fn(),
};
};

View File

@@ -12,5 +12,7 @@ export const newStorageRepositoryMock = (): jest.Mocked<IStorageRepository> => {
mkdirSync: jest.fn(),
checkDiskUsage: jest.fn(),
readdir: jest.fn(),
stat: jest.fn(),
crawl: jest.fn(),
};
};

175
server/test/test-utils.ts Normal file
View File

@@ -0,0 +1,175 @@
import {
AdminSignupResponseDto,
AlbumResponseDto,
AuthDeviceResponseDto,
AuthUserDto,
CreateUserDto,
LibraryResponseDto,
LoginCredentialDto,
LoginResponseDto,
SharedLinkCreateDto,
SharedLinkResponseDto,
UpdateUserDto,
UserResponseDto,
} from '@app/domain';
import { CreateAlbumDto } from '@app/domain/album/dto/album-create.dto';
import { dataSource } from '@app/infra';
import { UserEntity } from '@app/infra/entities';
import request from 'supertest';
import { adminSignupStub, loginResponseStub, loginStub, signupResponseStub } from './fixtures';
export const db = {
reset: async () => {
if (!dataSource.isInitialized) {
await dataSource.initialize();
}
await dataSource.transaction(async (em) => {
for (const entity of dataSource.entityMetadatas) {
if (entity.tableName === 'users') {
continue;
}
await em.query(`DELETE FROM ${entity.tableName} CASCADE;`);
}
await em.query(`DELETE FROM "users" CASCADE;`);
});
},
disconnect: async () => {
if (dataSource.isInitialized) {
await dataSource.destroy();
}
},
};
export function getAuthUser(): AuthUserDto {
return {
id: '3108ac14-8afb-4b7e-87fd-39ebb6b79750',
email: 'test@email.com',
isAdmin: false,
};
}
export const api = {
adminSignUp: async (server: any) => {
const { status, body } = await request(server).post('/auth/admin-sign-up').send(adminSignupStub);
expect(status).toBe(201);
expect(body).toEqual(signupResponseStub);
return body as AdminSignupResponseDto;
},
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;
},
userCreate: async (server: any, accessToken: string, user: Partial<UserEntity>) => {
const { status, body } = await request(server)
.post('/user')
.set('Authorization', `Bearer ${accessToken}`)
.send(user);
expect(status).toBe(201);
return body as UserResponseDto;
},
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 response = await request(server).post('/auth/validateToken').set('Authorization', `Bearer ${accessToken}`);
expect(response.body).toEqual({ authStatus: true });
expect(response.status).toBe(200);
},
albumApi: {
create: async (server: any, accessToken: string, dto: CreateAlbumDto) => {
const res = await request(server).post('/album').set('Authorization', `Bearer ${accessToken}`).send(dto);
expect(res.status).toEqual(201);
return res.body as AlbumResponseDto;
},
},
libraryApi: {
getAll: async (server: any, accessToken: string) => {
const res = await request(server).get('/library').set('Authorization', `Bearer ${accessToken}`);
expect(res.status).toEqual(200);
expect(Array.isArray(res.body)).toBe(true);
return res.body as LibraryResponseDto[];
},
},
sharedLinkApi: {
create: async (server: any, accessToken: string, dto: SharedLinkCreateDto) => {
const { status, body } = await request(server)
.post('/shared-link')
.set('Authorization', `Bearer ${accessToken}`)
.send(dto);
expect(status).toBe(201);
return body as SharedLinkResponseDto;
},
},
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;
},
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;
},
},
} as const;