refactor(server): split api and jobs into separate e2e suites (#6307)

* refactor: domain and infra modules

* refactor(server): e2e tests
This commit is contained in:
Jason Rasmussen
2024-01-09 23:04:16 -05:00
committed by GitHub
parent e5786b200a
commit bf1dd36fa9
50 changed files with 852 additions and 439 deletions

View File

@@ -0,0 +1,14 @@
import { ActivityCreateDto, ActivityResponseDto } from '@app/domain';
import request from 'supertest';
export const activityApi = {
create: async (server: any, accessToken: string, dto: ActivityCreateDto) => {
const res = await request(server).post('/activity').set('Authorization', `Bearer ${accessToken}`).send(dto);
expect(res.status === 200 || res.status === 201).toBe(true);
return res.body as ActivityResponseDto;
},
delete: async (server: any, accessToken: string, id: string) => {
const res = await request(server).delete(`/activity/${id}`).set('Authorization', `Bearer ${accessToken}`);
expect(res.status).toEqual(204);
},
};

View File

@@ -0,0 +1,28 @@
import { AddUsersDto, AlbumResponseDto, BulkIdResponseDto, BulkIdsDto, CreateAlbumDto } from '@app/domain';
import request from 'supertest';
export const 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;
},
addAssets: async (server: any, accessToken: string, id: string, dto: BulkIdsDto) => {
const res = await request(server)
.put(`/album/${id}/assets`)
.set('Authorization', `Bearer ${accessToken}`)
.send(dto);
expect(res.status).toEqual(200);
return res.body as BulkIdResponseDto[];
},
addUsers: async (server: any, accessToken: string, id: string, dto: AddUsersDto) => {
const res = await request(server).put(`/album/${id}/users`).set('Authorization', `Bearer ${accessToken}`).send(dto);
expect(res.status).toEqual(200);
return res.body as AlbumResponseDto;
},
getAllAlbums: async (server: any, accessToken: string) => {
const res = await request(server).get(`/album/`).set('Authorization', `Bearer ${accessToken}`).send();
expect(res.status).toEqual(200);
return res.body as AlbumResponseDto[];
},
};

View File

@@ -0,0 +1,16 @@
import { APIKeyCreateResponseDto } from '@app/domain';
import { apiKeyCreateStub } from '@test';
import request from 'supertest';
export const apiKeyApi = {
createApiKey: async (server: any, accessToken: string) => {
const { status, body } = await request(server)
.post('/api-key')
.set('Authorization', `Bearer ${accessToken}`)
.send(apiKeyCreateStub);
expect(status).toBe(201);
return body as APIKeyCreateResponseDto;
},
};

View File

@@ -0,0 +1,79 @@
import { AssetResponseDto } from '@app/domain';
import { CreateAssetDto } from '@app/immich/api-v1/asset/dto/create-asset.dto';
import { AssetFileUploadResponseDto } from '@app/immich/api-v1/asset/response-dto/asset-file-upload-response.dto';
import { randomBytes } from 'crypto';
import request from 'supertest';
type UploadDto = Partial<CreateAssetDto> & { content?: Buffer };
const asset = {
deviceAssetId: 'test-1',
deviceId: 'test',
fileCreatedAt: new Date(),
fileModifiedAt: new Date(),
};
export const assetApi = {
create: async (
server: any,
accessToken: string,
dto?: Omit<CreateAssetDto, 'assetData'>,
): Promise<AssetResponseDto> => {
dto = dto || asset;
const { status, body } = await request(server)
.post(`/asset/upload`)
.field('deviceAssetId', dto.deviceAssetId)
.field('deviceId', dto.deviceId)
.field('fileCreatedAt', dto.fileCreatedAt.toISOString())
.field('fileModifiedAt', dto.fileModifiedAt.toISOString())
.attach('assetData', randomBytes(32), 'example.jpg')
.set('Authorization', `Bearer ${accessToken}`);
expect([200, 201].includes(status)).toBe(true);
return body as AssetResponseDto;
},
get: async (server: any, accessToken: string, id: string): Promise<AssetResponseDto> => {
const { body, status } = await request(server)
.get(`/asset/assetById/${id}`)
.set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(200);
return body as AssetResponseDto;
},
getAllAssets: async (server: any, accessToken: string) => {
const { body, status } = await request(server).get(`/asset/`).set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(200);
return body as AssetResponseDto[];
},
upload: async (server: any, accessToken: string, id: string, dto: UploadDto = {}) => {
const { content, isFavorite = false, isArchived = false } = dto;
const { body, status } = await request(server)
.post('/asset/upload')
.set('Authorization', `Bearer ${accessToken}`)
.field('deviceAssetId', id)
.field('deviceId', 'TEST')
.field('fileCreatedAt', new Date().toISOString())
.field('fileModifiedAt', new Date().toISOString())
.field('isFavorite', isFavorite)
.field('isArchived', isArchived)
.field('duration', '0:00:00.000000')
.attach('assetData', content || randomBytes(32), 'example.jpg');
expect(status).toBe(201);
return body as AssetFileUploadResponseDto;
},
getWebpThumbnail: async (server: any, accessToken: string, assetId: string) => {
const { body, status } = await request(server)
.get(`/asset/thumbnail/${assetId}`)
.set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(200);
return body;
},
getJpegThumbnail: async (server: any, accessToken: string, assetId: string) => {
const { body, status } = await request(server)
.get(`/asset/thumbnail/${assetId}?format=JPEG`)
.set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(200);
return body;
},
};

View File

@@ -0,0 +1,45 @@
import { AuthDeviceResponseDto, LoginCredentialDto, LoginResponseDto, UserResponseDto } from '@app/domain';
import { adminSignupStub, loginResponseStub, loginStub } from '@test';
import request from 'supertest';
export const authApi = {
adminSignUp: async (server: any) => {
const { status, body } = await request(server).post('/auth/admin-sign-up').send(adminSignupStub);
expect(status).toBe(201);
return body as UserResponseDto;
},
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;
},
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 { status, body } = await request(server)
.post('/auth/validateToken')
.set('Authorization', `Bearer ${accessToken}`);
expect(body).toEqual({ authStatus: true });
expect(status).toBe(200);
},
};

View File

@@ -0,0 +1,23 @@
import { activityApi } from './activity-api';
import { albumApi } from './album-api';
import { apiKeyApi } from './api-key-api';
import { assetApi } from './asset-api';
import { authApi } from './auth-api';
import { libraryApi } from './library-api';
import { partnerApi } from './partner-api';
import { serverInfoApi } from './server-info-api';
import { sharedLinkApi } from './shared-link-api';
import { userApi } from './user-api';
export const api = {
activityApi,
authApi,
apiKeyApi,
assetApi,
libraryApi,
serverInfoApi,
sharedLinkApi,
albumApi,
userApi,
partnerApi,
};

View File

@@ -0,0 +1,47 @@
import { CreateLibraryDto, LibraryResponseDto, LibraryStatsResponseDto, ScanLibraryDto } 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[];
},
create: async (server: any, accessToken: string, dto: CreateLibraryDto) => {
const { body, status } = await request(server)
.post(`/library/`)
.set('Authorization', `Bearer ${accessToken}`)
.send(dto);
expect(status).toBe(201);
return body as LibraryResponseDto;
},
setImportPaths: async (server: any, accessToken: string, id: string, importPaths: string[]) => {
const { body, status } = await request(server)
.put(`/library/${id}`)
.set('Authorization', `Bearer ${accessToken}`)
.send({ importPaths });
expect(status).toBe(200);
return body as LibraryResponseDto;
},
scanLibrary: async (server: any, accessToken: string, id: string, dto: ScanLibraryDto = {}) => {
const { status } = await request(server)
.post(`/library/${id}/scan`)
.set('Authorization', `Bearer ${accessToken}`)
.send(dto);
expect(status).toBe(201);
},
removeOfflineFiles: async (server: any, accessToken: string, id: string) => {
const { status } = await request(server)
.post(`/library/${id}/removeOffline`)
.set('Authorization', `Bearer ${accessToken}`)
.send();
expect(status).toBe(201);
},
getLibraryStatistics: async (server: any, accessToken: string, id: string): Promise<LibraryStatsResponseDto> => {
const { body, status } = await request(server)
.get(`/library/${id}/statistics`)
.set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(200);
return body;
},
};

View File

@@ -0,0 +1,10 @@
import { PartnerResponseDto } from '@app/domain';
import request from 'supertest';
export const partnerApi = {
create: async (server: any, accessToken: string, id: string) => {
const { status, body } = await request(server).post(`/partner/${id}`).set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(201);
return body as PartnerResponseDto;
},
};

View File

@@ -0,0 +1,10 @@
import { ServerConfigDto } from '@app/domain';
import request from 'supertest';
export const serverInfoApi = {
getConfig: async (server: any) => {
const res = await request(server).get('/server-info/config');
expect(res.status).toBe(200);
return res.body as ServerConfigDto;
},
};

View File

@@ -0,0 +1,20 @@
import { SharedLinkCreateDto, SharedLinkResponseDto } from '@app/domain';
import request from 'supertest';
export const 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;
},
getMySharedLink: async (server: any, key: string) => {
const { status, body } = await request(server).get('/shared-link/me').query({ key });
expect(status).toBe(200);
return body as SharedLinkResponseDto;
},
};

View File

@@ -0,0 +1,50 @@
import { CreateUserDto, UpdateUserDto, UserResponseDto } from '@app/domain';
import request from 'supertest';
export const 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;
},
setExternalPath: async (server: any, accessToken: string, id: string, externalPath: string) => {
return await userApi.update(server, accessToken, { id, externalPath });
},
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;
},
};

View File

@@ -0,0 +1,24 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"modulePaths": ["<rootDir>"],
"rootDir": "../..",
"globalSetup": "<rootDir>/e2e/api/setup.ts",
"testEnvironment": "node",
"testMatch": ["**/e2e/api/specs/*.e2e-spec.[tj]s"],
"testTimeout": 60000,
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"<rootDir>/src/**/*.(t|j)s",
"!<rootDir>/src/**/*.spec.(t|s)s",
"!<rootDir>/src/infra/migrations/**"
],
"coverageDirectory": "./coverage",
"moduleNameMapper": {
"^@test(|/.*)$": "<rootDir>/test/$1",
"^@app/immich(|/.*)$": "<rootDir>/src/immich/$1",
"^@app/infra(|/.*)$": "<rootDir>/src/infra/$1",
"^@app/domain(|/.*)$": "<rootDir>/src/domain/$1"
}
}

16
server/e2e/api/setup.ts Normal file
View File

@@ -0,0 +1,16 @@
import { PostgreSqlContainer } from '@testcontainers/postgresql';
export default async () => {
const pg = await new PostgreSqlContainer('tensorchord/pgvecto-rs:pg14-v0.1.11')
.withDatabase('immich')
.withUsername('postgres')
.withPassword('postgres')
.withReuse()
.withCommand(['-c', 'fsync=off', '-c', 'shared_preload_libraries=vectors.so'])
.start();
process.env.DB_URL = pg.getConnectionUri();
process.env.NODE_ENV = 'development';
process.env.LOG_LEVEL = 'fatal';
process.env.TZ = 'Z';
};

View File

@@ -0,0 +1,402 @@
import { AlbumResponseDto, LoginResponseDto, ReactionType } from '@app/domain';
import { ActivityController } from '@app/immich';
import { AssetFileUploadResponseDto } from '@app/immich/api-v1/asset/response-dto/asset-file-upload-response.dto';
import { ActivityEntity } from '@app/infra/entities';
import { errorStub, userDto, uuidStub } from '@test/fixtures';
import request from 'supertest';
import { api } from '../client';
import { testApp } from '../utils';
describe(`${ActivityController.name} (e2e)`, () => {
let server: any;
let admin: LoginResponseDto;
let asset: AssetFileUploadResponseDto;
let album: AlbumResponseDto;
let nonOwner: LoginResponseDto;
beforeAll(async () => {
server = (await testApp.create()).getHttpServer();
await testApp.reset();
await api.authApi.adminSignUp(server);
admin = await api.authApi.adminLogin(server);
asset = await api.assetApi.upload(server, admin.accessToken, 'example');
await api.userApi.create(server, admin.accessToken, userDto.user1);
nonOwner = await api.authApi.login(server, userDto.user1);
album = await api.albumApi.create(server, admin.accessToken, {
albumName: 'Album 1',
assetIds: [asset.id],
sharedWithUserIds: [nonOwner.userId],
});
});
afterAll(async () => {
await testApp.teardown();
});
beforeEach(async () => {
await testApp.reset({ entities: [ActivityEntity] });
});
describe('GET /activity', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).get('/activity');
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should require an albumId', async () => {
const { status, body } = await request(server)
.get('/activity')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(400);
expect(body).toEqual(errorStub.badRequest(expect.arrayContaining(['albumId must be a UUID'])));
});
it('should reject an invalid albumId', async () => {
const { status, body } = await request(server)
.get('/activity')
.query({ albumId: uuidStub.invalid })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(400);
expect(body).toEqual(errorStub.badRequest(expect.arrayContaining(['albumId must be a UUID'])));
});
it('should reject an invalid assetId', async () => {
const { status, body } = await request(server)
.get('/activity')
.query({ albumId: uuidStub.notFound, assetId: uuidStub.invalid })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(400);
expect(body).toEqual(errorStub.badRequest(expect.arrayContaining(['assetId must be a UUID'])));
});
it('should start off empty', async () => {
const { status, body } = await request(server)
.get('/activity')
.query({ albumId: album.id })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(body).toEqual([]);
expect(status).toEqual(200);
});
it('should filter by album id', async () => {
const album2 = await api.albumApi.create(server, admin.accessToken, {
albumName: 'Album 2',
assetIds: [asset.id],
});
const [reaction] = await Promise.all([
api.activityApi.create(server, admin.accessToken, {
albumId: album.id,
type: ReactionType.LIKE,
}),
api.activityApi.create(server, admin.accessToken, {
albumId: album2.id,
type: ReactionType.LIKE,
}),
]);
const { status, body } = await request(server)
.get('/activity')
.query({ albumId: album.id })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(200);
expect(body.length).toBe(1);
expect(body[0]).toEqual(reaction);
});
it('should filter by type=comment', async () => {
const [reaction] = await Promise.all([
api.activityApi.create(server, admin.accessToken, {
albumId: album.id,
type: ReactionType.COMMENT,
comment: 'comment',
}),
api.activityApi.create(server, admin.accessToken, { albumId: album.id, type: ReactionType.LIKE }),
]);
const { status, body } = await request(server)
.get('/activity')
.query({ albumId: album.id, type: 'comment' })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(200);
expect(body.length).toBe(1);
expect(body[0]).toEqual(reaction);
});
it('should filter by type=like', async () => {
const [reaction] = await Promise.all([
api.activityApi.create(server, admin.accessToken, { albumId: album.id, type: ReactionType.LIKE }),
api.activityApi.create(server, admin.accessToken, {
albumId: album.id,
type: ReactionType.COMMENT,
comment: 'comment',
}),
]);
const { status, body } = await request(server)
.get('/activity')
.query({ albumId: album.id, type: 'like' })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(200);
expect(body.length).toBe(1);
expect(body[0]).toEqual(reaction);
});
it('should filter by userId', async () => {
const [reaction] = await Promise.all([
api.activityApi.create(server, admin.accessToken, { albumId: album.id, type: ReactionType.LIKE }),
]);
const response1 = await request(server)
.get('/activity')
.query({ albumId: album.id, userId: uuidStub.notFound })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(response1.status).toEqual(200);
expect(response1.body.length).toBe(0);
const response2 = await request(server)
.get('/activity')
.query({ albumId: album.id, userId: admin.userId })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(response2.status).toEqual(200);
expect(response2.body.length).toBe(1);
expect(response2.body[0]).toEqual(reaction);
});
it('should filter by assetId', async () => {
const [reaction] = await Promise.all([
api.activityApi.create(server, admin.accessToken, {
albumId: album.id,
assetId: asset.id,
type: ReactionType.LIKE,
}),
api.activityApi.create(server, admin.accessToken, { albumId: album.id, type: ReactionType.LIKE }),
]);
const { status, body } = await request(server)
.get('/activity')
.query({ albumId: album.id, assetId: asset.id })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(200);
expect(body.length).toBe(1);
expect(body[0]).toEqual(reaction);
});
});
describe('POST /activity', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).post('/activity');
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should require an albumId', async () => {
const { status, body } = await request(server)
.post('/activity')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ albumId: uuidStub.invalid });
expect(status).toEqual(400);
expect(body).toEqual(errorStub.badRequest(expect.arrayContaining(['albumId must be a UUID'])));
});
it('should require a comment when type is comment', async () => {
const { status, body } = await request(server)
.post('/activity')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ albumId: uuidStub.notFound, type: 'comment', comment: null });
expect(status).toEqual(400);
expect(body).toEqual(errorStub.badRequest(['comment must be a string', 'comment should not be empty']));
});
it('should add a comment to an album', async () => {
const { status, body } = await request(server)
.post('/activity')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ albumId: album.id, type: 'comment', comment: 'This is my first comment' });
expect(status).toEqual(201);
expect(body).toEqual({
id: expect.any(String),
assetId: null,
createdAt: expect.any(String),
type: 'comment',
comment: 'This is my first comment',
user: expect.objectContaining({ email: admin.userEmail }),
});
});
it('should add a like to an album', async () => {
const { status, body } = await request(server)
.post('/activity')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ albumId: album.id, type: 'like' });
expect(status).toEqual(201);
expect(body).toEqual({
id: expect.any(String),
assetId: null,
createdAt: expect.any(String),
type: 'like',
comment: null,
user: expect.objectContaining({ email: admin.userEmail }),
});
});
it('should return a 200 for a duplicate like on the album', async () => {
const reaction = await api.activityApi.create(server, admin.accessToken, {
albumId: album.id,
type: ReactionType.LIKE,
});
const { status, body } = await request(server)
.post('/activity')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ albumId: album.id, type: 'like' });
expect(status).toEqual(200);
expect(body).toEqual(reaction);
});
it('should not confuse an album like with an asset like', async () => {
const reaction = await api.activityApi.create(server, admin.accessToken, {
albumId: album.id,
assetId: asset.id,
type: ReactionType.LIKE,
});
const { status, body } = await request(server)
.post('/activity')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ albumId: album.id, type: 'like' });
expect(status).toEqual(201);
expect(body.id).not.toEqual(reaction.id);
});
it('should add a comment to an asset', async () => {
const { status, body } = await request(server)
.post('/activity')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ albumId: album.id, assetId: asset.id, type: 'comment', comment: 'This is my first comment' });
expect(status).toEqual(201);
expect(body).toEqual({
id: expect.any(String),
assetId: asset.id,
createdAt: expect.any(String),
type: 'comment',
comment: 'This is my first comment',
user: expect.objectContaining({ email: admin.userEmail }),
});
});
it('should add a like to an asset', async () => {
const { status, body } = await request(server)
.post('/activity')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ albumId: album.id, assetId: asset.id, type: 'like' });
expect(status).toEqual(201);
expect(body).toEqual({
id: expect.any(String),
assetId: asset.id,
createdAt: expect.any(String),
type: 'like',
comment: null,
user: expect.objectContaining({ email: admin.userEmail }),
});
});
it('should return a 200 for a duplicate like on an asset', async () => {
const reaction = await api.activityApi.create(server, admin.accessToken, {
albumId: album.id,
assetId: asset.id,
type: ReactionType.LIKE,
});
const { status, body } = await request(server)
.post('/activity')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ albumId: album.id, assetId: asset.id, type: 'like' });
expect(status).toEqual(200);
expect(body).toEqual(reaction);
});
});
describe('DELETE /activity/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).delete(`/activity/${uuidStub.notFound}`);
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should require a valid uuid', async () => {
const { status, body } = await request(server)
.delete(`/activity/${uuidStub.invalid}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest(['id must be a UUID']));
});
it('should remove a comment from an album', async () => {
const reaction = await api.activityApi.create(server, admin.accessToken, {
albumId: album.id,
type: ReactionType.COMMENT,
comment: 'This is a test comment',
});
const { status } = await request(server)
.delete(`/activity/${reaction.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(204);
});
it('should remove a like from an album', async () => {
const reaction = await api.activityApi.create(server, admin.accessToken, {
albumId: album.id,
type: ReactionType.LIKE,
});
const { status } = await request(server)
.delete(`/activity/${reaction.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(204);
});
it('should let the owner remove a comment by another user', async () => {
const reaction = await api.activityApi.create(server, nonOwner.accessToken, {
albumId: album.id,
type: ReactionType.COMMENT,
comment: 'This is a test comment',
});
const { status } = await request(server)
.delete(`/activity/${reaction.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(204);
});
it('should not let a user remove a comment by another user', async () => {
const reaction = await api.activityApi.create(server, admin.accessToken, {
albumId: album.id,
type: ReactionType.COMMENT,
comment: 'This is a test comment',
});
const { status, body } = await request(server)
.delete(`/activity/${reaction.id}`)
.set('Authorization', `Bearer ${nonOwner.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest('Not found or no activity.delete access'));
});
it('should let a non-owner remove their own comment', async () => {
const reaction = await api.activityApi.create(server, nonOwner.accessToken, {
albumId: album.id,
type: ReactionType.COMMENT,
comment: 'This is a test comment',
});
const { status } = await request(server)
.delete(`/activity/${reaction.id}`)
.set('Authorization', `Bearer ${nonOwner.accessToken}`);
expect(status).toBe(204);
});
});
});

View File

@@ -0,0 +1,448 @@
import { AlbumResponseDto, LoginResponseDto } from '@app/domain';
import { AlbumController } from '@app/immich';
import { AssetFileUploadResponseDto } from '@app/immich/api-v1/asset/response-dto/asset-file-upload-response.dto';
import { SharedLinkType } from '@app/infra/entities';
import { errorStub, userDto, uuidStub } from '@test/fixtures';
import request from 'supertest';
import { api } from '../client';
import { testApp } from '../utils';
const user1SharedUser = 'user1SharedUser';
const user1SharedLink = 'user1SharedLink';
const user1NotShared = 'user1NotShared';
const user2SharedUser = 'user2SharedUser';
const user2SharedLink = 'user2SharedLink';
const user2NotShared = 'user2NotShared';
describe(`${AlbumController.name} (e2e)`, () => {
let server: any;
let admin: LoginResponseDto;
let user1: LoginResponseDto;
let user1Asset: AssetFileUploadResponseDto;
let user1Albums: AlbumResponseDto[];
let user2: LoginResponseDto;
let user2Albums: AlbumResponseDto[];
beforeAll(async () => {
server = (await testApp.create()).getHttpServer();
});
afterAll(async () => {
await testApp.teardown();
});
beforeEach(async () => {
await testApp.reset();
await api.authApi.adminSignUp(server);
admin = await api.authApi.adminLogin(server);
await Promise.all([
api.userApi.create(server, admin.accessToken, userDto.user1),
api.userApi.create(server, admin.accessToken, userDto.user2),
]);
[user1, user2] = await Promise.all([
api.authApi.login(server, userDto.user1),
api.authApi.login(server, userDto.user2),
]);
user1Asset = await api.assetApi.upload(server, user1.accessToken, 'example');
const albums = await Promise.all([
// user 1
api.albumApi.create(server, user1.accessToken, {
albumName: user1SharedUser,
sharedWithUserIds: [user2.userId],
assetIds: [user1Asset.id],
}),
api.albumApi.create(server, user1.accessToken, { albumName: user1SharedLink, assetIds: [user1Asset.id] }),
api.albumApi.create(server, user1.accessToken, { albumName: user1NotShared, assetIds: [user1Asset.id] }),
// user 2
api.albumApi.create(server, user2.accessToken, {
albumName: user2SharedUser,
sharedWithUserIds: [user1.userId],
assetIds: [user1Asset.id],
}),
api.albumApi.create(server, user2.accessToken, { albumName: user2SharedLink }),
api.albumApi.create(server, user2.accessToken, { albumName: user2NotShared }),
]);
user1Albums = albums.slice(0, 3);
user2Albums = albums.slice(3);
await Promise.all([
// add shared link to user1SharedLink album
api.sharedLinkApi.create(server, user1.accessToken, {
type: SharedLinkType.ALBUM,
albumId: user1Albums[1].id,
}),
// add shared link to user2SharedLink album
api.sharedLinkApi.create(server, user2.accessToken, {
type: SharedLinkType.ALBUM,
albumId: user2Albums[1].id,
}),
]);
});
describe('GET /album', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).get('/album');
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should reject an invalid shared param', async () => {
const { status, body } = await request(server)
.get('/album?shared=invalid')
.set('Authorization', `Bearer ${user1.accessToken}`);
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).toEqual(400);
expect(body).toEqual(errorStub.badRequest(['assetId must be a UUID']));
});
it('should not return shared albums with a deleted owner', async () => {
await api.userApi.delete(server, admin.accessToken, user1.userId);
const { status, body } = await request(server)
.get('/album?shared=true')
.set('Authorization', `Bearer ${user2.accessToken}`);
expect(status).toBe(200);
expect(body).toHaveLength(1);
expect(body).toEqual(
expect.arrayContaining([
expect.objectContaining({ ownerId: user2.userId, albumName: user2SharedLink, shared: true }),
]),
);
});
it('should return the album collection including owned and shared', async () => {
const { status, body } = await request(server).get('/album').set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toHaveLength(3);
expect(body).toEqual(
expect.arrayContaining([
expect.objectContaining({ ownerId: user1.userId, albumName: user1SharedUser, shared: true }),
expect.objectContaining({ ownerId: user1.userId, albumName: user1SharedLink, shared: true }),
expect.objectContaining({ ownerId: user1.userId, albumName: user1NotShared, shared: false }),
]),
);
});
it('should return the album collection filtered by shared', async () => {
const { status, body } = await request(server)
.get('/album?shared=true')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toHaveLength(3);
expect(body).toEqual(
expect.arrayContaining([
expect.objectContaining({ ownerId: user1.userId, albumName: user1SharedUser, shared: true }),
expect.objectContaining({ ownerId: user1.userId, albumName: user1SharedLink, shared: true }),
expect.objectContaining({ ownerId: user2.userId, albumName: user2SharedUser, shared: true }),
]),
);
});
it('should return the album collection filtered by NOT shared', async () => {
const { status, body } = await request(server)
.get('/album?shared=false')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toHaveLength(1);
expect(body).toEqual(
expect.arrayContaining([
expect.objectContaining({ ownerId: user1.userId, albumName: user1NotShared, shared: false }),
]),
);
});
it('should return the album collection filtered by assetId', async () => {
const asset = await api.assetApi.upload(server, user1.accessToken, 'example2');
await api.albumApi.addAssets(server, user1.accessToken, user1Albums[0].id, { ids: [asset.id] });
const { status, body } = await request(server)
.get(`/album?assetId=${asset.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toHaveLength(1);
});
it('should return the album collection filtered by assetId and ignores shared=true', async () => {
const { status, body } = await request(server)
.get(`/album?shared=true&assetId=${user1Asset.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toHaveLength(4);
});
it('should return the album collection filtered by assetId and ignores shared=false', async () => {
const { status, body } = await request(server)
.get(`/album?shared=false&assetId=${user1Asset.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toHaveLength(4);
});
});
describe('POST /album', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).post('/album').send({ albumName: 'New album' });
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should create an album', async () => {
const body = await api.albumApi.create(server, user1.accessToken, { albumName: 'New album' });
expect(body).toEqual({
id: expect.any(String),
createdAt: expect.any(String),
updatedAt: expect.any(String),
ownerId: user1.userId,
albumName: 'New album',
description: '',
albumThumbnailAssetId: null,
shared: false,
sharedUsers: [],
hasSharedLink: false,
assets: [],
assetCount: 0,
owner: expect.objectContaining({ email: user1.userEmail }),
isActivityEnabled: true,
});
});
});
describe('GET /album/count', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).get('/album/count');
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should return total count of albums the user has access to', async () => {
const { status, body } = await request(server)
.get('/album/count')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({ owned: 3, shared: 3, notShared: 1 });
});
});
describe('GET /album/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).get(`/album/${user1Albums[0].id}`);
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should return album info for own album', async () => {
const { status, body } = await request(server)
.get(`/album/${user1Albums[0].id}?withoutAssets=false`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual(user1Albums[0]);
});
it('should return album info for shared album', async () => {
const { status, body } = await request(server)
.get(`/album/${user2Albums[0].id}?withoutAssets=false`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual(user2Albums[0]);
});
it('should return album info with assets when withoutAssets is undefined', async () => {
const { status, body } = await request(server)
.get(`/album/${user1Albums[0].id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual(user1Albums[0]);
});
it('should return album info without assets when withoutAssets is true', async () => {
const { status, body } = await request(server)
.get(`/album/${user1Albums[0].id}?withoutAssets=true`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({
...user1Albums[0],
assets: [],
assetCount: 1,
});
});
});
describe('PUT /album/:id/assets', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).put(`/album/${user1Albums[0].id}/assets`);
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should be able to add own asset to own album', async () => {
const asset = await api.assetApi.upload(server, user1.accessToken, 'example1');
const { status, body } = await request(server)
.put(`/album/${user1Albums[0].id}/assets`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ ids: [asset.id] });
expect(status).toBe(200);
expect(body).toEqual([expect.objectContaining({ id: asset.id, success: true })]);
});
it('should be able to add own asset to shared album', async () => {
const asset = await api.assetApi.upload(server, user1.accessToken, 'example1');
const { status, body } = await request(server)
.put(`/album/${user2Albums[0].id}/assets`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ ids: [asset.id] });
expect(status).toBe(200);
expect(body).toEqual([expect.objectContaining({ id: asset.id, success: true })]);
});
});
describe('PATCH /album/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(server)
.patch(`/album/${uuidStub.notFound}`)
.send({ albumName: 'New album name' });
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should update an album', async () => {
const album = await api.albumApi.create(server, user1.accessToken, { albumName: 'New album' });
const { status, body } = await request(server)
.patch(`/album/${album.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
albumName: 'New album name',
description: 'An album description',
});
expect(status).toBe(200);
expect(body).toEqual({
...album,
updatedAt: expect.any(String),
albumName: 'New album name',
description: 'An album description',
});
});
});
describe('DELETE /album/:id/assets', () => {
it('should require authentication', async () => {
const { status, body } = await request(server)
.delete(`/album/${user1Albums[0].id}/assets`)
.send({ ids: [user1Asset.id] });
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should be able to remove own asset from own album', async () => {
const { status, body } = await request(server)
.delete(`/album/${user1Albums[0].id}/assets`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ ids: [user1Asset.id] });
expect(status).toBe(200);
expect(body).toEqual([expect.objectContaining({ id: user1Asset.id, success: true })]);
});
it('should be able to remove own asset from shared album', async () => {
const { status, body } = await request(server)
.delete(`/album/${user2Albums[0].id}/assets`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ ids: [user1Asset.id] });
expect(status).toBe(200);
expect(body).toEqual([expect.objectContaining({ id: user1Asset.id, success: true })]);
});
it('should not be able to remove foreign asset from own album', async () => {
const { status, body } = await request(server)
.delete(`/album/${user2Albums[0].id}/assets`)
.set('Authorization', `Bearer ${user2.accessToken}`)
.send({ ids: [user1Asset.id] });
expect(status).toBe(200);
expect(body).toEqual([expect.objectContaining({ id: user1Asset.id, success: false, error: 'no_permission' })]);
});
it('should not be able to remove foreign asset from foreign album', async () => {
const { status, body } = await request(server)
.delete(`/album/${user1Albums[0].id}/assets`)
.set('Authorization', `Bearer ${user2.accessToken}`)
.send({ ids: [user1Asset.id] });
expect(status).toBe(200);
expect(body).toEqual([expect.objectContaining({ id: user1Asset.id, success: false, error: 'no_permission' })]);
});
});
describe('PUT :id/users', () => {
let album: AlbumResponseDto;
beforeEach(async () => {
album = await api.albumApi.create(server, user1.accessToken, { albumName: 'testAlbum' });
});
it('should require authentication', async () => {
const { status, body } = await request(server)
.put(`/album/${user1Albums[0].id}/users`)
.send({ sharedUserIds: [] });
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should be able to add user to own album', async () => {
const { status, body } = await request(server)
.put(`/album/${album.id}/users`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ sharedUserIds: [user2.userId] });
expect(status).toBe(200);
expect(body).toEqual(expect.objectContaining({ sharedUsers: [expect.objectContaining({ id: user2.userId })] }));
});
it('should not be able to share album with owner', async () => {
const { status, body } = await request(server)
.put(`/album/${album.id}/users`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ sharedUserIds: [user1.userId] });
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest('Cannot be shared with owner'));
});
it('should not be able to add existing user to shared album', async () => {
await request(server)
.put(`/album/${album.id}/users`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ sharedUserIds: [user2.userId] });
const { status, body } = await request(server)
.put(`/album/${album.id}/users`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ sharedUserIds: [user2.userId] });
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest('User already added'));
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,291 @@
import { AuthController } from '@app/immich';
import {
adminSignupStub,
changePasswordStub,
deviceStub,
errorStub,
loginResponseStub,
loginStub,
uuidStub,
} from '@test/fixtures';
import request from 'supertest';
import { api } from '../client';
import { testApp } from '../utils';
const name = 'Immich Admin';
const password = 'Password123';
const email = 'admin@immich.app';
const adminSignupResponse = {
avatarColor: expect.any(String),
id: expect.any(String),
name: 'Immich Admin',
email: 'admin@immich.app',
storageLabel: 'admin',
externalPath: null,
profileImagePath: '',
// why? lol
shouldChangePassword: true,
isAdmin: true,
createdAt: expect.any(String),
updatedAt: expect.any(String),
deletedAt: null,
oauthId: '',
memoriesEnabled: true,
};
describe(`${AuthController.name} (e2e)`, () => {
let server: any;
let accessToken: string;
beforeAll(async () => {
server = (await testApp.create()).getHttpServer();
});
afterAll(async () => {
await testApp.teardown();
});
beforeEach(async () => {
await testApp.reset();
await api.authApi.adminSignUp(server);
const response = await api.authApi.adminLogin(server);
accessToken = response.accessToken;
});
describe('POST /auth/admin-sign-up', () => {
beforeEach(async () => {
await testApp.reset();
});
const invalid = [
{
should: 'require an email address',
data: { name, password },
},
{
should: 'require a password',
data: { name, email },
},
{
should: 'require a name',
data: { email, password },
},
{
should: 'require a valid email',
data: { name, 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());
});
}
it(`should sign up the admin`, async () => {
await api.authApi.adminSignUp(server);
});
it('should sign up the admin with a local domain', async () => {
const { status, body } = await request(server)
.post('/auth/admin-sign-up')
.send({ ...adminSignupStub, email: 'admin@local' });
expect(status).toEqual(201);
expect(body).toEqual({ ...adminSignupResponse, email: 'admin@local' });
});
it('should transform email to lower case', async () => {
const { status, body } = await request(server)
.post('/auth/admin-sign-up')
.send({ ...adminSignupStub, email: 'aDmIn@IMMICH.app' });
expect(status).toEqual(201);
expect(body).toEqual(adminSignupResponse);
});
it('should not allow a second admin to sign up', async () => {
await api.authApi.adminSignUp(server);
const { status, body } = await request(server).post('/auth/admin-sign-up').send(adminSignupStub);
expect(status).toBe(400);
expect(body).toEqual(errorStub.alreadyHasAdmin);
});
for (const key of Object.keys(adminSignupStub)) {
it(`should not allow null ${key}`, async () => {
const { status, body } = await request(server)
.post('/auth/admin-sign-up')
.send({ ...adminSignupStub, [key]: null });
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest());
});
}
});
describe(`POST /auth/login`, () => {
it('should reject an incorrect password', async () => {
const { status, body } = await request(server).post('/auth/login').send({ email, password: 'incorrect' });
expect(body).toEqual(errorStub.incorrectLogin);
expect(status).toBe(401);
});
for (const key of Object.keys(loginStub.admin)) {
it(`should not allow null ${key}`, async () => {
const { status, body } = await request(server)
.post('/auth/login')
.send({ ...loginStub.admin, [key]: null });
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest());
});
}
it('should accept a correct password', async () => {
const { status, body, headers } = await request(server).post('/auth/login').send({ email, password });
expect(status).toBe(201);
expect(body).toEqual(loginResponseStub.admin.response);
const token = body.accessToken;
expect(token).toBeDefined();
const cookies = headers['set-cookie'];
expect(cookies).toHaveLength(2);
expect(cookies[0]).toEqual(`immich_access_token=${token}; HttpOnly; Path=/; Max-Age=34560000; SameSite=Lax;`);
expect(cookies[1]).toEqual('immich_auth_type=password; HttpOnly; Path=/; Max-Age=34560000; SameSite=Lax;');
});
});
describe('GET /auth/devices', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).get('/auth/devices');
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should get a list of authorized devices', async () => {
const { status, body } = await request(server).get('/auth/devices').set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(200);
expect(body).toEqual([deviceStub.current]);
});
});
describe('DELETE /auth/devices', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).delete(`/auth/devices`);
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should logout all devices (except the current one)', async () => {
for (let i = 0; i < 5; i++) {
await api.authApi.adminLogin(server);
}
await expect(api.authApi.getAuthDevices(server, accessToken)).resolves.toHaveLength(6);
const { status } = await request(server).delete(`/auth/devices`).set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(204);
await api.authApi.validateToken(server, accessToken);
});
});
describe('DELETE /auth/devices/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).delete(`/auth/devices/${uuidStub.notFound}`);
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should throw an error for a non-existent device id', async () => {
const { status, body } = await request(server)
.delete(`/auth/devices/${uuidStub.notFound}`)
.set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest('Not found or no authDevice.delete access'));
});
it('should logout a device', async () => {
const [device] = await api.authApi.getAuthDevices(server, accessToken);
const { status } = await request(server)
.delete(`/auth/devices/${device.id}`)
.set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(204);
const response = await request(server).post('/auth/validateToken').set('Authorization', `Bearer ${accessToken}`);
expect(response.body).toEqual(errorStub.invalidToken);
expect(response.status).toBe(401);
});
});
describe('POST /auth/validateToken', () => {
it('should reject an invalid token', async () => {
const { status, body } = await request(server).post(`/auth/validateToken`).set('Authorization', 'Bearer 123');
expect(status).toBe(401);
expect(body).toEqual(errorStub.invalidToken);
});
it('should accept a valid token', async () => {
const { status, body } = await request(server)
.post(`/auth/validateToken`)
.send({})
.set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({ authStatus: true });
});
});
describe('POST /auth/change-password', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).post(`/auth/change-password`).send(changePasswordStub);
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
for (const key of Object.keys(changePasswordStub)) {
it(`should not allow null ${key}`, async () => {
const { status, body } = await request(server)
.post('/auth/change-password')
.send({ ...changePasswordStub, [key]: null })
.set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest());
});
}
it('should require the current password', async () => {
const { status, body } = await request(server)
.post(`/auth/change-password`)
.send({ ...changePasswordStub, password: 'wrong-password' })
.set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorStub.wrongPassword);
});
it('should change the password', async () => {
const { status } = await request(server)
.post(`/auth/change-password`)
.send(changePasswordStub)
.set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(200);
await api.authApi.login(server, { email: 'admin@immich.app', password: 'Password1234' });
});
});
describe('POST /auth/logout', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).post(`/auth/logout`);
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should logout the user', async () => {
const { status, body } = await request(server).post(`/auth/logout`).set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({ successful: true, redirectUri: '/auth/login?autoLaunch=0' });
});
});
});

View File

@@ -0,0 +1,387 @@
import { LibraryResponseDto, LoginResponseDto } from '@app/domain';
import { LibraryController } from '@app/immich';
import { LibraryType } from '@app/infra/entities';
import { errorStub, userDto, uuidStub } from '@test/fixtures';
import request from 'supertest';
import { api } from '../client';
import { testApp } from '../utils';
describe(`${LibraryController.name} (e2e)`, () => {
let server: any;
let admin: LoginResponseDto;
beforeAll(async () => {
server = (await testApp.create()).getHttpServer();
});
afterAll(async () => {
await testApp.teardown();
});
beforeEach(async () => {
await testApp.reset();
await api.authApi.adminSignUp(server);
admin = await api.authApi.adminLogin(server);
});
describe('GET /library', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).get('/library');
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should start with a default upload library', async () => {
const { status, body } = await request(server)
.get('/library')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toHaveLength(1);
expect(body).toEqual([
expect.objectContaining({
ownerId: admin.userId,
type: LibraryType.UPLOAD,
name: 'Default Library',
refreshedAt: null,
assetCount: 0,
importPaths: [],
exclusionPatterns: [],
}),
]);
});
});
describe('POST /library', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).post('/library').send({});
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should create an external library with defaults', async () => {
const { status, body } = await request(server)
.post('/library')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ type: LibraryType.EXTERNAL });
expect(status).toBe(201);
expect(body).toEqual(
expect.objectContaining({
ownerId: admin.userId,
type: LibraryType.EXTERNAL,
name: 'New External Library',
refreshedAt: null,
assetCount: 0,
importPaths: [],
exclusionPatterns: [],
}),
);
});
it('should create an external library with options', async () => {
const { status, body } = await request(server)
.post('/library')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({
type: LibraryType.EXTERNAL,
name: 'My Awesome Library',
importPaths: ['/path/to/import'],
exclusionPatterns: ['**/Raw/**'],
});
expect(status).toBe(201);
expect(body).toEqual(
expect.objectContaining({
name: 'My Awesome Library',
importPaths: ['/path/to/import'],
}),
);
});
it('should create an upload library with defaults', async () => {
const { status, body } = await request(server)
.post('/library')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ type: LibraryType.UPLOAD });
expect(status).toBe(201);
expect(body).toEqual(
expect.objectContaining({
ownerId: admin.userId,
type: LibraryType.UPLOAD,
name: 'New Upload Library',
refreshedAt: null,
assetCount: 0,
importPaths: [],
exclusionPatterns: [],
}),
);
});
it('should create an upload library with options', async () => {
const { status, body } = await request(server)
.post('/library')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ type: LibraryType.UPLOAD, name: 'My Awesome Library' });
expect(status).toBe(201);
expect(body).toEqual(
expect.objectContaining({
name: 'My Awesome Library',
}),
);
});
it('should not allow upload libraries to have import paths', async () => {
const { status, body } = await request(server)
.post('/library')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ type: LibraryType.UPLOAD, importPaths: ['/path/to/import'] });
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest('Upload libraries cannot have import paths'));
});
it('should not allow upload libraries to have exclusion patterns', async () => {
const { status, body } = await request(server)
.post('/library')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ type: LibraryType.UPLOAD, exclusionPatterns: ['**/Raw/**'] });
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest('Upload libraries cannot have exclusion patterns'));
});
it('should allow a non-admin to create a library', async () => {
await api.userApi.create(server, admin.accessToken, userDto.user1);
const user1 = await api.authApi.login(server, userDto.user1);
const { status, body } = await request(server)
.post('/library')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ type: LibraryType.EXTERNAL });
expect(status).toBe(201);
expect(body).toEqual(
expect.objectContaining({
ownerId: user1.userId,
type: LibraryType.EXTERNAL,
name: 'New External Library',
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 library: LibraryResponseDto;
beforeEach(async () => {
// Create an external library with default settings
library = await api.libraryApi.create(server, admin.accessToken, { type: LibraryType.EXTERNAL });
});
it('should change the library name', async () => {
const { status, body } = await request(server)
.put(`/library/${library.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ name: 'New Library Name' });
expect(status).toBe(200);
expect(body).toEqual(
expect.objectContaining({
name: 'New Library Name',
}),
);
});
it('should not set an empty name', async () => {
const { status, body } = await request(server)
.put(`/library/${library.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ name: '' });
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest(['name should not be empty']));
});
it('should change the import paths', async () => {
const { status, body } = await request(server)
.put(`/library/${library.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ importPaths: ['/path/to/import'] });
expect(status).toBe(200);
expect(body).toEqual(
expect.objectContaining({
importPaths: ['/path/to/import'],
}),
);
});
it('should not allow an empty import path', async () => {
const { status, body } = await request(server)
.put(`/library/${library.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ importPaths: [''] });
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest(['each value in importPaths should not be empty']));
});
it('should change the exclusion pattern', async () => {
const { status, body } = await request(server)
.put(`/library/${library.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ exclusionPatterns: ['**/Raw/**'] });
expect(status).toBe(200);
expect(body).toEqual(
expect.objectContaining({
exclusionPatterns: ['**/Raw/**'],
}),
);
});
it('should not allow an empty exclusion pattern', async () => {
const { status, body } = await request(server)
.put(`/library/${library.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ exclusionPatterns: [''] });
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest(['each value in exclusionPatterns should not be empty']));
});
});
});
describe('GET /library/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).get(`/library/${uuidStub.notFound}`);
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should get library by id', async () => {
const library = await api.libraryApi.create(server, admin.accessToken, { type: LibraryType.EXTERNAL });
const { status, body } = await request(server)
.get(`/library/${library.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual(
expect.objectContaining({
ownerId: admin.userId,
type: LibraryType.EXTERNAL,
name: 'New External Library',
refreshedAt: null,
assetCount: 0,
importPaths: [],
exclusionPatterns: [],
}),
);
});
it("should not allow getting another user's library", async () => {
await Promise.all([
api.userApi.create(server, admin.accessToken, userDto.user1),
api.userApi.create(server, admin.accessToken, userDto.user2),
]);
const [user1, user2] = await Promise.all([
api.authApi.login(server, userDto.user1),
api.authApi.login(server, userDto.user2),
]);
const library = await api.libraryApi.create(server, user1.accessToken, { type: LibraryType.EXTERNAL });
const { status, body } = await request(server)
.get(`/library/${library.id}`)
.set('Authorization', `Bearer ${user2.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, admin.accessToken);
expect(defaultLibrary).toBeDefined();
const { status, body } = await request(server)
.delete(`/library/${defaultLibrary.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorStub.noDeleteUploadLibrary);
});
it('should delete an empty library', async () => {
const library = await api.libraryApi.create(server, admin.accessToken, { type: LibraryType.EXTERNAL });
const { status, body } = await request(server)
.delete(`/library/${library.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({});
const libraries = await api.libraryApi.getAll(server, admin.accessToken);
expect(libraries).toHaveLength(1);
expect(libraries).not.toEqual(
expect.arrayContaining([
expect.objectContaining({
id: library.id,
}),
]),
);
});
});
describe('GET /library/:id/statistics', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).get(`/library/${uuidStub.notFound}/statistics`);
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
});
describe('POST /library/:id/scan', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).post(`/library/${uuidStub.notFound}/scan`).send({});
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
});
describe('POST /library/:id/removeOffline', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).post(`/library/${uuidStub.notFound}/removeOffline`).send({});
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
});
});

View File

@@ -0,0 +1,30 @@
import { OAuthController } from '@app/immich';
import { errorStub } from '@test/fixtures';
import request from 'supertest';
import { api } from '../client';
import { testApp } from '../utils';
describe(`${OAuthController.name} (e2e)`, () => {
let server: any;
beforeAll(async () => {
server = (await testApp.create()).getHttpServer();
});
afterAll(async () => {
await testApp.teardown();
});
beforeEach(async () => {
await testApp.reset();
await api.authApi.adminSignUp(server);
});
describe('POST /oauth/authorize', () => {
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(['redirectUri must be a string', 'redirectUri should not be empty']));
});
});
});

View File

@@ -0,0 +1,143 @@
import { LoginResponseDto, PartnerDirection } from '@app/domain';
import { PartnerController } from '@app/immich';
import { errorStub, userDto } from '@test/fixtures';
import request from 'supertest';
import { api } from '../client';
import { testApp } from '../utils';
describe(`${PartnerController.name} (e2e)`, () => {
let server: any;
let user1: LoginResponseDto;
let user2: LoginResponseDto;
let user3: LoginResponseDto;
beforeAll(async () => {
server = (await testApp.create()).getHttpServer();
await testApp.reset();
await api.authApi.adminSignUp(server);
const admin = await api.authApi.adminLogin(server);
await Promise.all([
api.userApi.create(server, admin.accessToken, userDto.user1),
api.userApi.create(server, admin.accessToken, userDto.user2),
api.userApi.create(server, admin.accessToken, userDto.user3),
]);
[user1, user2, user3] = await Promise.all([
api.authApi.login(server, userDto.user1),
api.authApi.login(server, userDto.user2),
api.authApi.login(server, userDto.user3),
]);
await Promise.all([
api.partnerApi.create(server, user1.accessToken, user2.userId),
api.partnerApi.create(server, user2.accessToken, user1.userId),
]);
});
afterAll(async () => {
await testApp.teardown();
});
describe('GET /partner', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).get('/partner');
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should get all partners shared by user', async () => {
const { status, body } = await request(server)
.get('/partner')
.set('Authorization', `Bearer ${user1.accessToken}`)
.query({ direction: PartnerDirection.SharedBy });
expect(status).toBe(200);
expect(body).toEqual([expect.objectContaining({ id: user2.userId })]);
});
it('should get all partners that share with user', async () => {
const { status, body } = await request(server)
.get('/partner')
.set('Authorization', `Bearer ${user1.accessToken}`)
.query({ direction: PartnerDirection.SharedWith });
expect(status).toBe(200);
expect(body).toEqual([expect.objectContaining({ id: user2.userId })]);
});
});
describe('POST /partner/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).post(`/partner/${user3.userId}`);
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should share with new partner', async () => {
const { status, body } = await request(server)
.post(`/partner/${user3.userId}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(201);
expect(body).toEqual(expect.objectContaining({ id: user3.userId }));
});
it('should not share with new partner if already sharing with this partner', async () => {
const { status, body } = await request(server)
.post(`/partner/${user2.userId}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(expect.objectContaining({ message: 'Partner already exists' }));
});
});
describe('PUT /partner/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).put(`/partner/${user2.userId}`);
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should update partner', async () => {
const { status, body } = await request(server)
.put(`/partner/${user2.userId}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ inTimeline: false });
expect(status).toBe(200);
expect(body).toEqual(expect.objectContaining({ id: user2.userId, inTimeline: false }));
});
});
describe('DELETE /partner/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).delete(`/partner/${user3.userId}`);
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should delete partner', async () => {
const { status } = await request(server)
.delete(`/partner/${user3.userId}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
});
it('should throw a bad request if partner not found', async () => {
const { status, body } = await request(server)
.delete(`/partner/${user3.userId}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(expect.objectContaining({ message: 'Partner not found' }));
});
});
});

View File

@@ -0,0 +1,189 @@
import { IPersonRepository, LoginResponseDto } from '@app/domain';
import { PersonController } from '@app/immich';
import { PersonEntity } from '@app/infra/entities';
import { INestApplication } from '@nestjs/common';
import { errorStub, uuidStub } from '@test/fixtures';
import request from 'supertest';
import { api } from '../client';
import { testApp } from '../utils';
describe(`${PersonController.name}`, () => {
let app: INestApplication;
let server: any;
let loginResponse: LoginResponseDto;
let accessToken: string;
let personRepository: IPersonRepository;
let visiblePerson: PersonEntity;
let hiddenPerson: PersonEntity;
beforeAll(async () => {
app = await testApp.create();
server = app.getHttpServer();
personRepository = app.get<IPersonRepository>(IPersonRepository);
});
afterAll(async () => {
await testApp.teardown();
});
beforeEach(async () => {
await testApp.reset();
await api.authApi.adminSignUp(server);
loginResponse = await api.authApi.adminLogin(server);
accessToken = loginResponse.accessToken;
const faceAsset = await api.assetApi.upload(server, accessToken, 'face_asset');
visiblePerson = await personRepository.create({
ownerId: loginResponse.userId,
name: 'visible_person',
thumbnailPath: '/thumbnail/face_asset',
});
await personRepository.createFace({
assetId: faceAsset.id,
personId: visiblePerson.id,
embedding: Array.from({ length: 512 }, Math.random),
});
hiddenPerson = await personRepository.create({
ownerId: loginResponse.userId,
name: 'hidden_person',
isHidden: true,
thumbnailPath: '/thumbnail/face_asset',
});
await personRepository.createFace({
assetId: faceAsset.id,
personId: hiddenPerson.id,
embedding: Array.from({ length: 512 }, Math.random),
});
});
describe('GET /person', () => {
beforeEach(async () => {});
it('should require authentication', async () => {
const { status, body } = await request(server).get('/person');
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should return all people (including hidden)', async () => {
const { status, body } = await request(server)
.get('/person')
.set('Authorization', `Bearer ${accessToken}`)
.query({ withHidden: true });
expect(status).toBe(200);
expect(body).toEqual({
total: 2,
visible: 1,
people: [
expect.objectContaining({ name: 'visible_person' }),
expect.objectContaining({ name: 'hidden_person' }),
],
});
});
it('should return only visible people', async () => {
const { status, body } = await request(server).get('/person').set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({
total: 1,
visible: 1,
people: [expect.objectContaining({ name: 'visible_person' })],
});
});
});
describe('GET /person/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).get(`/person/${uuidStub.notFound}`);
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should throw error if person with id does not exist', async () => {
const { status, body } = await request(server)
.get(`/person/${uuidStub.notFound}`)
.set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest());
});
it('should return person information', async () => {
const { status, body } = await request(server)
.get(`/person/${visiblePerson.id}`)
.set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(200);
expect(body).toEqual(expect.objectContaining({ id: visiblePerson.id }));
});
});
describe('PUT /person/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).put(`/person/${uuidStub.notFound}`);
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
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([`${key} must be a ${type}`]));
});
}
it('should not accept invalid birth dates', async () => {
for (const { birthDate, response } of [
{ birthDate: false, response: 'Not found or no person.write access' },
{ birthDate: 'false', response: ['birthDate must be a Date instance'] },
{ birthDate: '123567', response: 'Not found or no person.write access' },
{ birthDate: 123567, response: 'Not found or no person.write access' },
]) {
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(response));
}
});
it('should update a date of birth', async () => {
const { status, body } = await request(server)
.put(`/person/${visiblePerson.id}`)
.set('Authorization', `Bearer ${accessToken}`)
.send({ birthDate: '1990-01-01T05:00:00.000Z' });
expect(status).toBe(200);
expect(body).toMatchObject({ birthDate: '1990-01-01' });
});
it('should clear a date of birth', async () => {
const person = await personRepository.create({
birthDate: new Date('1990-01-01'),
ownerId: loginResponse.userId,
});
expect(person.birthDate).toBeDefined();
const { status, body } = await request(server)
.put(`/person/${person.id}`)
.set('Authorization', `Bearer ${accessToken}`)
.send({ birthDate: null });
expect(status).toBe(200);
expect(body).toMatchObject({ birthDate: null });
});
});
});

View File

@@ -0,0 +1,215 @@
import {
AssetResponseDto,
IAssetRepository,
ISmartInfoRepository,
LibraryResponseDto,
LoginResponseDto,
mapAsset,
} from '@app/domain';
import { SearchController } from '@app/immich';
import { INestApplication } from '@nestjs/common';
import { errorStub, searchStub } from '@test/fixtures';
import request from 'supertest';
import { api } from '../client';
import { generateAsset, testApp } from '../utils';
describe(`${SearchController.name}`, () => {
let app: INestApplication;
let server: any;
let loginResponse: LoginResponseDto;
let accessToken: string;
let libraries: LibraryResponseDto[];
let assetRepository: IAssetRepository;
let smartInfoRepository: ISmartInfoRepository;
let asset1: AssetResponseDto;
beforeAll(async () => {
app = await testApp.create();
server = app.getHttpServer();
assetRepository = app.get<IAssetRepository>(IAssetRepository);
smartInfoRepository = app.get<ISmartInfoRepository>(ISmartInfoRepository);
});
afterAll(async () => {
await testApp.teardown();
});
beforeEach(async () => {
await testApp.reset();
await api.authApi.adminSignUp(server);
loginResponse = await api.authApi.adminLogin(server);
accessToken = loginResponse.accessToken;
libraries = await api.libraryApi.getAll(server, accessToken);
});
describe('GET /search (exif)', () => {
beforeEach(async () => {
const assetId = (await assetRepository.create(generateAsset(loginResponse.userId, libraries))).id;
await assetRepository.upsertExif({ assetId, ...searchStub.exif });
const assetWithMetadata = await assetRepository.getById(assetId, { exifInfo: true });
if (!assetWithMetadata) {
throw new Error('Asset not found');
}
asset1 = mapAsset(assetWithMetadata);
});
it('should require authentication', async () => {
const { status, body } = await request(server).get('/search');
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should return assets when searching by exif', async () => {
if (!asset1?.exifInfo?.make) {
throw new Error('Asset 1 does not have exif info');
}
const { status, body } = await request(server)
.get('/search')
.set('Authorization', `Bearer ${accessToken}`)
.query({ q: asset1.exifInfo.make });
expect(status).toBe(200);
expect(body).toMatchObject({
albums: {
total: 0,
count: 0,
items: [],
facets: [],
},
assets: {
total: 1,
count: 1,
items: [
{
id: asset1.id,
exifInfo: {
make: asset1.exifInfo.make,
},
},
],
facets: [],
},
});
});
it('should be case-insensitive for metadata search', async () => {
if (!asset1?.exifInfo?.make) {
throw new Error('Asset 1 does not have exif info');
}
const { status, body } = await request(server)
.get('/search')
.set('Authorization', `Bearer ${accessToken}`)
.query({ q: asset1.exifInfo.make.toLowerCase() });
expect(status).toBe(200);
expect(body).toMatchObject({
albums: {
total: 0,
count: 0,
items: [],
facets: [],
},
assets: {
total: 1,
count: 1,
items: [
{
id: asset1.id,
exifInfo: {
make: asset1.exifInfo.make,
},
},
],
facets: [],
},
});
});
it('should be whitespace-insensitive for metadata search', async () => {
if (!asset1?.exifInfo?.make) {
throw new Error('Asset 1 does not have exif info');
}
const { status, body } = await request(server)
.get('/search')
.set('Authorization', `Bearer ${accessToken}`)
.query({ q: ` ${asset1.exifInfo.make} ` });
expect(status).toBe(200);
expect(body).toMatchObject({
albums: {
total: 0,
count: 0,
items: [],
facets: [],
},
assets: {
total: 1,
count: 1,
items: [
{
id: asset1.id,
exifInfo: {
make: asset1.exifInfo.make,
},
},
],
facets: [],
},
});
});
});
describe('GET /search (smart info)', () => {
beforeEach(async () => {
const assetId = (await assetRepository.create(generateAsset(loginResponse.userId, libraries))).id;
await assetRepository.upsertExif({ assetId, ...searchStub.exif });
await smartInfoRepository.upsert({ assetId, ...searchStub.smartInfo }, Array.from({ length: 512 }, Math.random));
const assetWithMetadata = await assetRepository.getById(assetId, { exifInfo: true, smartInfo: true });
if (!assetWithMetadata) {
throw new Error('Asset not found');
}
asset1 = mapAsset(assetWithMetadata);
});
it('should return assets when searching by object', async () => {
if (!asset1?.smartInfo?.objects) {
throw new Error('Asset 1 does not have smart info');
}
const { status, body } = await request(server)
.get('/search')
.set('Authorization', `Bearer ${accessToken}`)
.query({ q: asset1.smartInfo.objects[0] });
expect(status).toBe(200);
expect(body).toMatchObject({
albums: {
total: 0,
count: 0,
items: [],
facets: [],
},
assets: {
total: 1,
count: 1,
items: [
{
id: asset1.id,
smartInfo: {
objects: asset1.smartInfo.objects,
tags: asset1.smartInfo.tags,
},
},
],
facets: [],
},
});
});
});
});

View File

@@ -0,0 +1,186 @@
import { LoginResponseDto } from '@app/domain';
import { ServerInfoController } from '@app/immich';
import { errorStub, userDto } from '@test/fixtures';
import request from 'supertest';
import { api } from '../client';
import { testApp } from '../utils';
describe(`${ServerInfoController.name} (e2e)`, () => {
let server: any;
let admin: LoginResponseDto;
let nonAdmin: LoginResponseDto;
beforeAll(async () => {
server = (await testApp.create()).getHttpServer();
await testApp.reset();
await api.authApi.adminSignUp(server);
admin = await api.authApi.adminLogin(server);
await api.userApi.create(server, admin.accessToken, userDto.user1);
nonAdmin = await api.authApi.login(server, userDto.user1);
});
afterAll(async () => {
await testApp.teardown();
});
describe('GET /server-info', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).get('/server-info');
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should return the disk information', async () => {
const { status, body } = await request(server)
.get('/server-info')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({
diskAvailable: expect.any(String),
diskAvailableRaw: expect.any(Number),
diskSize: expect.any(String),
diskSizeRaw: expect.any(Number),
diskUsagePercentage: expect.any(Number),
diskUse: expect.any(String),
diskUseRaw: expect.any(Number),
});
});
});
describe('GET /server-info/ping', () => {
it('should respond with pong', async () => {
const { status, body } = await request(server).get('/server-info/ping');
expect(status).toBe(200);
expect(body).toEqual({ res: 'pong' });
});
});
describe('GET /server-info/version', () => {
it('should respond with the server version', async () => {
const { status, body } = await request(server).get('/server-info/version');
expect(status).toBe(200);
expect(body).toEqual({
major: expect.any(Number),
minor: expect.any(Number),
patch: expect.any(Number),
});
});
});
describe('GET /server-info/features', () => {
it('should respond with the server features', async () => {
const { status, body } = await request(server).get('/server-info/features');
expect(status).toBe(200);
expect(body).toEqual({
clipEncode: true,
configFile: false,
facialRecognition: true,
map: true,
reverseGeocoding: true,
oauth: false,
oauthAutoLaunch: false,
passwordLogin: true,
search: true,
sidecar: true,
trash: true,
});
});
});
describe('GET /server-info/config', () => {
it('should respond with the server configuration', async () => {
const { status, body } = await request(server).get('/server-info/config');
expect(status).toBe(200);
expect(body).toEqual({
loginPageMessage: '',
oauthButtonText: 'Login with OAuth',
trashDays: 30,
isInitialized: true,
externalDomain: '',
isOnboarded: false,
});
});
});
describe('GET /server-info/statistics', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).get('/server-info/statistics');
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should only work for admins', async () => {
const { status, body } = await request(server)
.get('/server-info/statistics')
.set('Authorization', `Bearer ${nonAdmin.accessToken}`);
expect(status).toBe(403);
expect(body).toEqual(errorStub.forbidden);
});
it('should return the server stats', async () => {
const { status, body } = await request(server)
.get('/server-info/statistics')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({
photos: 0,
usage: 0,
usageByUser: [
{
photos: 0,
usage: 0,
userName: 'Immich Admin',
userId: admin.userId,
videos: 0,
},
{
photos: 0,
usage: 0,
userName: 'User 1',
userId: nonAdmin.userId,
videos: 0,
},
],
videos: 0,
});
});
});
describe('GET /server-info/media-types', () => {
it('should return accepted media types', async () => {
const { status, body } = await request(server).get('/server-info/media-types');
expect(status).toBe(200);
expect(body).toEqual({
sidecar: ['.xmp'],
image: expect.any(Array),
video: expect.any(Array),
});
});
});
describe('GET /server-info/theme', () => {
it('should respond with the server theme', async () => {
const { status, body } = await request(server).get('/server-info/theme');
expect(status).toBe(200);
expect(body).toEqual({
customCss: '',
});
});
});
describe('POST /server-info/admin-onboarding', () => {
it('should set admin onboarding', async () => {
const config = await api.serverInfoApi.getConfig(server);
expect(config.isOnboarded).toBe(false);
const { status } = await request(server)
.post('/server-info/admin-onboarding')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(204);
const newConfig = await api.serverInfoApi.getConfig(server);
expect(newConfig.isOnboarded).toBe(true);
});
});
});

View File

@@ -0,0 +1,422 @@
import {
AlbumResponseDto,
AssetResponseDto,
IAssetRepository,
LoginResponseDto,
SharedLinkResponseDto,
} from '@app/domain';
import { SharedLinkController } from '@app/immich';
import { SharedLinkType } from '@app/infra/entities';
import { INestApplication } from '@nestjs/common';
import { errorStub, userDto, uuidStub } from '@test/fixtures';
import { DateTime } from 'luxon';
import request from 'supertest';
import { api } from '../client';
import { testApp } from '../utils';
describe(`${SharedLinkController.name} (e2e)`, () => {
let server: any;
let admin: LoginResponseDto;
let asset1: AssetResponseDto;
let asset2: AssetResponseDto;
let user1: LoginResponseDto;
let user2: LoginResponseDto;
let album: AlbumResponseDto;
let metadataAlbum: AlbumResponseDto;
let deletedAlbum: AlbumResponseDto;
let linkWithDeletedAlbum: SharedLinkResponseDto;
let linkWithPassword: SharedLinkResponseDto;
let linkWithAlbum: SharedLinkResponseDto;
let linkWithAssets: SharedLinkResponseDto;
let linkWithMetadata: SharedLinkResponseDto;
let linkWithoutMetadata: SharedLinkResponseDto;
let app: INestApplication<any>;
beforeAll(async () => {
app = await testApp.create();
server = app.getHttpServer();
const assetRepository = app.get<IAssetRepository>(IAssetRepository);
await testApp.reset();
await api.authApi.adminSignUp(server);
admin = await api.authApi.adminLogin(server);
await Promise.all([
api.userApi.create(server, admin.accessToken, userDto.user1),
api.userApi.create(server, admin.accessToken, userDto.user2),
]);
[user1, user2] = await Promise.all([
api.authApi.login(server, userDto.user1),
api.authApi.login(server, userDto.user2),
]);
[asset1, asset2] = await Promise.all([
api.assetApi.create(server, user1.accessToken),
api.assetApi.create(server, user1.accessToken),
]);
await assetRepository.upsertExif({
assetId: asset1.id,
longitude: -108.400968333333,
latitude: 39.115,
orientation: '1',
dateTimeOriginal: DateTime.fromISO('2022-01-10T19:15:44.310Z').toJSDate(),
timeZone: 'UTC-4',
state: 'Mesa County, Colorado',
country: 'United States of America',
});
[album, deletedAlbum, metadataAlbum] = await Promise.all([
api.albumApi.create(server, user1.accessToken, { albumName: 'album' }),
api.albumApi.create(server, user2.accessToken, { albumName: 'deleted album' }),
api.albumApi.create(server, user1.accessToken, { albumName: 'metadata album', assetIds: [asset1.id] }),
]);
[linkWithDeletedAlbum, linkWithAlbum, linkWithAssets, linkWithPassword, linkWithMetadata, linkWithoutMetadata] =
await Promise.all([
api.sharedLinkApi.create(server, user2.accessToken, {
type: SharedLinkType.ALBUM,
albumId: deletedAlbum.id,
}),
api.sharedLinkApi.create(server, user1.accessToken, {
type: SharedLinkType.ALBUM,
albumId: album.id,
}),
api.sharedLinkApi.create(server, user1.accessToken, {
type: SharedLinkType.INDIVIDUAL,
assetIds: [asset1.id],
}),
api.sharedLinkApi.create(server, user1.accessToken, {
type: SharedLinkType.ALBUM,
albumId: album.id,
password: 'foo',
}),
api.sharedLinkApi.create(server, user1.accessToken, {
type: SharedLinkType.ALBUM,
albumId: metadataAlbum.id,
showMetadata: true,
}),
api.sharedLinkApi.create(server, user1.accessToken, {
type: SharedLinkType.ALBUM,
albumId: metadataAlbum.id,
showMetadata: false,
}),
]);
await api.userApi.delete(server, admin.accessToken, user2.userId);
});
afterAll(async () => {
await testApp.teardown();
});
describe('GET /shared-link', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).get('/shared-link');
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should get all shared links created by user', async () => {
const { status, body } = await request(server)
.get('/shared-link')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toHaveLength(5);
expect(body).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: linkWithAlbum.id }),
expect.objectContaining({ id: linkWithAssets.id }),
expect.objectContaining({ id: linkWithPassword.id }),
expect.objectContaining({ id: linkWithMetadata.id }),
expect.objectContaining({ id: linkWithoutMetadata.id }),
]),
);
});
it('should not get shared links created by other users', async () => {
const { status, body } = await request(server)
.get('/shared-link')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual([]);
});
});
describe('GET /shared-link/me', () => {
it('should not require admin authentication', async () => {
const { status } = await request(server)
.get('/shared-link/me')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(403);
});
it('should get data for correct shared link', async () => {
const { status, body } = await request(server).get('/shared-link/me').query({ key: linkWithAlbum.key });
expect(status).toBe(200);
expect(body).toEqual(
expect.objectContaining({
album,
userId: user1.userId,
type: SharedLinkType.ALBUM,
}),
);
});
it('should return unauthorized for incorrect shared link', async () => {
const { status, body } = await request(server)
.get('/shared-link/me')
.query({ key: linkWithAlbum.key + 'foo' });
expect(status).toBe(401);
expect(body).toEqual(errorStub.invalidShareKey);
});
it('should return unauthorized if target has been soft deleted', async () => {
const { status, body } = await request(server).get('/shared-link/me').query({ key: linkWithDeletedAlbum.key });
expect(status).toBe(401);
expect(body).toEqual(errorStub.invalidShareKey);
});
it('should return unauthorized for password protected link', async () => {
const { status, body } = await request(server).get('/shared-link/me').query({ key: linkWithPassword.key });
expect(status).toBe(401);
expect(body).toEqual(errorStub.invalidSharePassword);
});
it('should get data for correct password protected link', async () => {
const { status, body } = await request(server)
.get('/shared-link/me')
.query({ key: linkWithPassword.key, password: 'foo' });
expect(status).toBe(200);
expect(body).toEqual(expect.objectContaining({ album, userId: user1.userId, type: SharedLinkType.ALBUM }));
});
it('should return metadata for album shared link', async () => {
const { status, body } = await request(server).get('/shared-link/me').query({ key: linkWithMetadata.key });
expect(status).toBe(200);
expect(body.assets).toHaveLength(1);
expect(body.assets[0]).toEqual(
expect.objectContaining({
originalFileName: 'example',
localDateTime: expect.any(String),
fileCreatedAt: expect.any(String),
exifInfo: expect.objectContaining({
longitude: -108.400968333333,
latitude: 39.115,
orientation: '1',
dateTimeOriginal: expect.any(String),
timeZone: 'UTC-4',
state: 'Mesa County, Colorado',
country: 'United States of America',
}),
}),
);
expect(body.album).toBeDefined();
});
it('should not return metadata for album shared link without metadata', async () => {
const { status, body } = await request(server).get('/shared-link/me').query({ key: linkWithoutMetadata.key });
expect(status).toBe(200);
expect(body.assets).toHaveLength(1);
expect(body.album).toBeDefined();
const asset = body.assets[0];
expect(asset).not.toHaveProperty('exifInfo');
expect(asset).not.toHaveProperty('fileCreatedAt');
expect(asset).not.toHaveProperty('originalFilename');
expect(asset).not.toHaveProperty('originalPath');
});
});
describe('GET /shared-link/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).get(`/shared-link/${linkWithAlbum.id}`);
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should get shared link by id', async () => {
const { status, body } = await request(server)
.get(`/shared-link/${linkWithAlbum.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual(expect.objectContaining({ album, userId: user1.userId, type: SharedLinkType.ALBUM }));
});
it('should not get shared link by id if user has not created the link or it does not exist', async () => {
const { status, body } = await request(server)
.get(`/shared-link/${linkWithAlbum.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(expect.objectContaining({ message: 'Shared link not found' }));
});
});
describe('POST /shared-link', () => {
it('should require authentication', async () => {
const { status, body } = await request(server)
.post('/shared-link')
.send({ type: SharedLinkType.ALBUM, albumId: uuidStub.notFound });
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should require a type and the correspondent asset/album id', async () => {
const { status, body } = await request(server)
.post('/shared-link')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest());
});
it('should require an asset/album id', async () => {
const { status, body } = await request(server)
.post('/shared-link')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ type: SharedLinkType.ALBUM });
expect(status).toBe(400);
expect(body).toEqual(expect.objectContaining({ message: 'Invalid albumId' }));
});
it('should require a valid asset id', async () => {
const { status, body } = await request(server)
.post('/shared-link')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ type: SharedLinkType.INDIVIDUAL, assetId: uuidStub.notFound });
expect(status).toBe(400);
expect(body).toEqual(expect.objectContaining({ message: 'Invalid assetIds' }));
});
it('should create a shared link', async () => {
const { status, body } = await request(server)
.post('/shared-link')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ type: SharedLinkType.ALBUM, albumId: album.id });
expect(status).toBe(201);
expect(body).toEqual(expect.objectContaining({ type: SharedLinkType.ALBUM, userId: user1.userId }));
});
});
describe('PATCH /shared-link/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(server)
.patch(`/shared-link/${linkWithAlbum.id}`)
.send({ description: 'foo' });
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should fail if invalid link', async () => {
const { status, body } = await request(server)
.patch(`/shared-link/${uuidStub.notFound}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ description: 'foo' });
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest());
});
it('should update shared link', async () => {
const { status, body } = await request(server)
.patch(`/shared-link/${linkWithAlbum.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ description: 'foo' });
expect(status).toBe(200);
expect(body).toEqual(
expect.objectContaining({ type: SharedLinkType.ALBUM, userId: user1.userId, description: 'foo' }),
);
});
});
describe('PUT /shared-link/:id/assets', () => {
it('should not add assets to shared link (album)', async () => {
const { status, body } = await request(server)
.put(`/shared-link/${linkWithAlbum.id}/assets`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ assetIds: [asset2.id] });
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest('Invalid shared link type'));
});
it('should add an assets to a shared link (individual)', async () => {
const { status, body } = await request(server)
.put(`/shared-link/${linkWithAssets.id}/assets`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ assetIds: [asset2.id] });
expect(body).toEqual([{ assetId: asset2.id, success: true }]);
expect(status).toBe(200);
});
});
describe('DELETE /shared-link/:id/assets', () => {
it('should not remove assets from a shared link (album)', async () => {
const { status, body } = await request(server)
.delete(`/shared-link/${linkWithAlbum.id}/assets`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ assetIds: [asset2.id] });
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest('Invalid shared link type'));
});
it('should remove assets from a shared link (individual)', async () => {
const { status, body } = await request(server)
.delete(`/shared-link/${linkWithAssets.id}/assets`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ assetIds: [asset2.id] });
expect(body).toEqual([{ assetId: asset2.id, success: true }]);
expect(status).toBe(200);
});
});
describe('DELETE /shared-link/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).delete(`/shared-link/${linkWithAlbum.id}`);
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should fail if invalid link', async () => {
const { status, body } = await request(server)
.delete(`/shared-link/${uuidStub.notFound}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest());
});
it('should delete a shared link', async () => {
const { status } = await request(server)
.delete(`/shared-link/${linkWithAlbum.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
});
});
});

View File

@@ -0,0 +1,72 @@
import { LoginResponseDto } from '@app/domain';
import { SystemConfigController } from '@app/immich';
import { errorStub, userDto } from '@test/fixtures';
import request from 'supertest';
import { api } from '../client';
import { testApp } from '../utils';
describe(`${SystemConfigController.name} (e2e)`, () => {
let server: any;
let admin: LoginResponseDto;
let nonAdmin: LoginResponseDto;
beforeAll(async () => {
server = (await testApp.create()).getHttpServer();
await testApp.reset();
await api.authApi.adminSignUp(server);
admin = await api.authApi.adminLogin(server);
await api.userApi.create(server, admin.accessToken, userDto.user1);
nonAdmin = await api.authApi.login(server, userDto.user1);
});
afterAll(async () => {
await testApp.teardown();
});
describe('GET /system-config/map/style.json', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).get('/system-config/map/style.json');
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should throw an error if a theme is not light or dark', async () => {
for (const theme of ['dark1', true, 123, '', null, undefined]) {
const { status, body } = await request(server)
.get('/system-config/map/style.json')
.query({ theme })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest(['theme must be one of the following values: light, dark']));
}
});
it('should return the light style.json', async () => {
const { status, body } = await request(server)
.get('/system-config/map/style.json')
.query({ theme: 'light' })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual(expect.objectContaining({ id: 'immich-map-light' }));
});
it('should return the dark style.json', async () => {
const { status, body } = await request(server)
.get('/system-config/map/style.json')
.query({ theme: 'dark' })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual(expect.objectContaining({ id: 'immich-map-dark' }));
});
it('should not require admin authentication', async () => {
const { status, body } = await request(server)
.get('/system-config/map/style.json')
.query({ theme: 'dark' })
.set('Authorization', `Bearer ${nonAdmin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual(expect.objectContaining({ id: 'immich-map-dark' }));
});
});
});

View File

@@ -0,0 +1,299 @@
import { LoginResponseDto, UserResponseDto, UserService } from '@app/domain';
import { AppModule, UserController } from '@app/immich';
import { UserEntity } from '@app/infra/entities';
import { INestApplication } from '@nestjs/common';
import { getRepositoryToken } from '@nestjs/typeorm';
import { errorStub, userDto, userSignupStub, userStub } from '@test/fixtures';
import request from 'supertest';
import { Repository } from 'typeorm';
import { api } from '../client';
import { testApp } from '../utils';
describe(`${UserController.name}`, () => {
let app: INestApplication;
let server: any;
let loginResponse: LoginResponseDto;
let accessToken: string;
let userService: UserService;
let userRepository: Repository<UserEntity>;
beforeAll(async () => {
app = await testApp.create();
server = app.getHttpServer();
userRepository = app.select(AppModule).get(getRepositoryToken(UserEntity));
});
afterAll(async () => {
await testApp.teardown();
});
beforeEach(async () => {
await testApp.reset();
await api.authApi.adminSignUp(server);
loginResponse = await api.authApi.adminLogin(server);
accessToken = loginResponse.accessToken;
userService = app.get<UserService>(UserService);
});
describe('GET /user', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).get('/user');
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should start with the admin', async () => {
const { status, body } = await request(server).get('/user').set('Authorization', `Bearer ${accessToken}`);
expect(status).toEqual(200);
expect(body).toHaveLength(1);
expect(body[0]).toMatchObject({ email: 'admin@immich.app' });
});
it('should hide deleted users', async () => {
const user1 = await api.userApi.create(server, accessToken, {
email: `user1@immich.app`,
password: 'Password123',
name: `User 1`,
});
await api.userApi.delete(server, accessToken, user1.id);
const { status, body } = await request(server)
.get(`/user`)
.query({ isAll: true })
.set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(200);
expect(body).toHaveLength(1);
expect(body[0]).toMatchObject({ email: 'admin@immich.app' });
});
it('should include deleted users', async () => {
const user1 = await api.userApi.create(server, accessToken, userDto.user1);
await api.userApi.delete(server, accessToken, user1.id);
const { status, body } = await request(server)
.get(`/user`)
.query({ isAll: false })
.set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(200);
expect(body).toHaveLength(2);
expect(body[0]).toMatchObject({ id: user1.id, email: 'user1@immich.app', deletedAt: expect.any(String) });
expect(body[1]).toMatchObject({ id: loginResponse.userId, email: 'admin@immich.app' });
});
});
describe('GET /user/info/:id', () => {
it('should require authentication', async () => {
const { status } = await request(server).get(`/user/info/${loginResponse.userId}`);
expect(status).toEqual(401);
});
it('should get the user info', async () => {
const { status, body } = await request(server)
.get(`/user/info/${loginResponse.userId}`)
.set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(200);
expect(body).toMatchObject({ id: loginResponse.userId, email: 'admin@immich.app' });
});
});
describe('GET /user/me', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).get(`/user/me`);
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should get my info', async () => {
const { status, body } = await request(server).get(`/user/me`).set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(200);
expect(body).toMatchObject({ id: loginResponse.userId, email: 'admin@immich.app' });
});
});
describe('POST /user', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).post(`/user`).send(userSignupStub);
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
for (const key of Object.keys(userSignupStub)) {
it(`should not allow null ${key}`, async () => {
const { status, body } = await request(server)
.post(`/user`)
.set('Authorization', `Bearer ${accessToken}`)
.send({ ...userSignupStub, [key]: null });
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest());
});
}
it('should ignore `isAdmin`', async () => {
const { status, body } = await request(server)
.post(`/user`)
.send({
isAdmin: true,
email: 'user1@immich.app',
password: 'Password123',
name: 'Immich',
})
.set('Authorization', `Bearer ${accessToken}`);
expect(body).toMatchObject({
email: 'user1@immich.app',
isAdmin: false,
shouldChangePassword: true,
});
expect(status).toBe(201);
});
it('should create a user without memories enabled', async () => {
const { status, body } = await request(server)
.post(`/user`)
.send({
email: 'no-memories@immich.app',
password: 'Password123',
name: 'No Memories',
memoriesEnabled: false,
})
.set('Authorization', `Bearer ${accessToken}`);
expect(body).toMatchObject({
email: 'no-memories@immich.app',
memoriesEnabled: false,
});
expect(status).toBe(201);
});
});
describe('DELETE /user/:id', () => {
let userToDelete: UserResponseDto;
beforeEach(async () => {
userToDelete = await api.userApi.create(server, accessToken, {
email: userStub.user1.email,
name: userStub.user1.name,
password: 'superSecurePassword',
});
});
it('should require authentication', async () => {
const { status, body } = await request(server).delete(`/user/${userToDelete.id}`);
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should delete user', async () => {
const deleteRequest = await request(server)
.delete(`/user/${userToDelete.id}`)
.set('Authorization', `Bearer ${accessToken}`);
expect(deleteRequest.status).toBe(200);
expect(deleteRequest.body).toEqual({
...userToDelete,
updatedAt: expect.any(String),
deletedAt: expect.any(String),
});
await userRepository.save({ id: deleteRequest.body.id, deletedAt: new Date('1970-01-01').toISOString() });
await userService.handleUserDelete({ id: userToDelete.id });
const { status, body } = await request(server)
.get('/user')
.query({ isAll: false })
.set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(200);
expect(body).toHaveLength(1);
});
});
describe('PUT /user', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).put(`/user`);
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
for (const key of Object.keys(userStub.admin)) {
it(`should not allow null ${key}`, async () => {
const { status, body } = await request(server)
.put(`/user`)
.set('Authorization', `Bearer ${accessToken}`)
.send({ ...userStub.admin, [key]: null });
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest());
});
}
it('should not allow a non-admin to become an admin', async () => {
const user = await api.userApi.create(server, accessToken, {
email: 'user1@immich.app',
password: 'Password123',
name: 'Immich User',
});
const { status, body } = await request(server)
.put(`/user`)
.send({ isAdmin: true, id: user.id })
.set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorStub.alreadyHasAdmin);
});
it('ignores updates to profileImagePath', async () => {
const user = await api.userApi.update(server, accessToken, {
id: loginResponse.userId,
profileImagePath: 'invalid.jpg',
} as any);
expect(user).toMatchObject({ id: loginResponse.userId, profileImagePath: '' });
});
it('should ignore updates to createdAt, updatedAt and deletedAt', async () => {
const before = await api.userApi.get(server, accessToken, loginResponse.userId);
const after = await api.userApi.update(server, accessToken, {
id: loginResponse.userId,
createdAt: '2023-01-01T00:00:00.000Z',
updatedAt: '2023-01-01T00:00:00.000Z',
deletedAt: '2023-01-01T00:00:00.000Z',
} as any);
expect(after).toStrictEqual(before);
});
it('should update first and last name', async () => {
const before = await api.userApi.get(server, accessToken, loginResponse.userId);
const after = await api.userApi.update(server, accessToken, {
id: before.id,
name: 'Name',
});
expect(after).toEqual({
...before,
updatedAt: expect.any(String),
name: 'Name',
});
expect(before.updatedAt).not.toEqual(after.updatedAt);
});
it('should update memories enabled', async () => {
const before = await api.userApi.get(server, accessToken, loginResponse.userId);
const after = await api.userApi.update(server, accessToken, {
id: before.id,
memoriesEnabled: false,
});
expect(after).toMatchObject({
...before,
updatedAt: expect.anything(),
memoriesEnabled: false,
});
expect(before.updatedAt).not.toEqual(after.updatedAt);
});
});
});

115
server/e2e/api/utils.ts Normal file
View File

@@ -0,0 +1,115 @@
import { AssetCreate, IJobRepository, IMetadataRepository, LibraryResponseDto } from '@app/domain';
import { AppModule } from '@app/immich';
import { InfraModule, InfraTestModule, dataSource } from '@app/infra';
import { AssetEntity, AssetType, LibraryType } from '@app/infra/entities';
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { randomBytes } from 'crypto';
import { DateTime } from 'luxon';
import { EntityTarget, ObjectLiteral } from 'typeorm';
import { AppService } from '../../src/microservices/app.service';
import { newJobRepositoryMock, newMetadataRepositoryMock } from '../../test';
export const today = DateTime.fromObject({ year: 2023, month: 11, day: 3 });
export const yesterday = today.minus({ days: 1 });
export interface ResetOptions {
entities?: EntityTarget<ObjectLiteral>[];
}
export const db = {
reset: async (options?: ResetOptions) => {
if (!dataSource.isInitialized) {
await dataSource.initialize();
}
await dataSource.transaction(async (em) => {
const entities = options?.entities || [];
const tableNames =
entities.length > 0
? entities.map((entity) => em.getRepository(entity).metadata.tableName)
: dataSource.entityMetadatas
.map((entity) => entity.tableName)
.filter((tableName) => !tableName.startsWith('geodata'));
let deleteUsers = false;
for (const tableName of tableNames) {
if (tableName === 'users') {
deleteUsers = true;
continue;
}
await em.query(`DELETE FROM ${tableName} CASCADE;`);
}
if (deleteUsers) {
await em.query(`DELETE FROM "users" CASCADE;`);
}
});
},
disconnect: async () => {
if (dataSource.isInitialized) {
await dataSource.destroy();
}
},
};
let app: INestApplication;
export const testApp = {
create: async (): Promise<INestApplication> => {
const moduleFixture = await Test.createTestingModule({ imports: [AppModule], providers: [AppService] })
.overrideModule(InfraModule)
.useModule(InfraTestModule)
.overrideProvider(IJobRepository)
.useValue(newJobRepositoryMock())
.overrideProvider(IMetadataRepository)
.useValue(newMetadataRepositoryMock())
.compile();
app = await moduleFixture.createNestApplication().init();
await app.get(AppService).init();
return app;
},
reset: async (options?: ResetOptions) => {
await db.reset(options);
},
teardown: async () => {
if (app) {
await app.get(AppService).teardown();
await app.close();
}
await db.disconnect();
},
};
function randomDate(start: Date, end: Date): Date {
return new Date(start.getTime() + Math.random() * (end.getTime() - start.getTime()));
}
let assetCount = 0;
export function generateAsset(
userId: string,
libraries: LibraryResponseDto[],
other: Partial<AssetEntity> = {},
): AssetCreate {
const id = assetCount++;
const { fileCreatedAt = randomDate(new Date(1970, 1, 1), new Date(2023, 1, 1)) } = other;
return {
createdAt: today.toJSDate(),
updatedAt: today.toJSDate(),
ownerId: userId,
checksum: randomBytes(20),
originalPath: `/tests/test_${id}`,
deviceAssetId: `test_${id}`,
deviceId: 'e2e-test',
libraryId: (
libraries.find(({ ownerId, type }) => ownerId === userId && type === LibraryType.UPLOAD) as LibraryResponseDto
).id,
isVisible: true,
fileCreatedAt,
fileModifiedAt: new Date(),
localDateTime: fileCreatedAt,
type: AssetType.IMAGE,
originalFileName: `test_${id}`,
...other,
};
}