Compare commits

..

1 Commits

Author SHA1 Message Date
Alex Tran 59384b90b0 feat: album order per user 2026-05-03 14:12:04 -05:00
60 changed files with 437 additions and 724 deletions
+3 -4
View File
@@ -28,10 +28,6 @@ export const errorDto = {
badRequest: (message: any = null) => ({ badRequest: (message: any = null) => ({
message: message ?? expect.anything(), message: message ?? expect.anything(),
}), }),
validationError: (errors?: ReadonlyArray<{ path: ReadonlyArray<string | number>; message: string }>) => ({
message: 'Validation failed',
errors: errors ? expect.arrayContaining(errors.map((e) => expect.objectContaining(e))) : expect.any(Array),
}),
noPermission: { noPermission: {
message: expect.stringContaining('Not found or no'), message: expect.stringContaining('Not found or no'),
}, },
@@ -41,6 +37,9 @@ export const errorDto = {
alreadyHasAdmin: { alreadyHasAdmin: {
message: 'The server already has an admin', message: 'The server already has an admin',
}, },
invalidEmail: {
message: ['email must be an email'],
},
}; };
export const signupResponseDto = { export const signupResponseDto = {
+7 -21
View File
@@ -110,9 +110,7 @@ describe('/libraries', () => {
}); });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(errorDto.badRequest(['[importPaths] Array must have unique items']));
errorDto.validationError([{ path: ['importPaths'], message: 'Array must have unique items' }]),
);
}); });
it('should not create an external library with duplicate exclusion patterns', async () => { it('should not create an external library with duplicate exclusion patterns', async () => {
@@ -127,9 +125,7 @@ describe('/libraries', () => {
}); });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(errorDto.badRequest(['[exclusionPatterns] Array must have unique items']));
errorDto.validationError([{ path: ['exclusionPatterns'], message: 'Array must have unique items' }]),
);
}); });
}); });
@@ -161,9 +157,7 @@ describe('/libraries', () => {
.send({ name: '' }); .send({ name: '' });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(errorDto.badRequest(['[name] Too small: expected string to have >=1 characters']));
errorDto.validationError([{ path: ['name'], message: 'Too small: expected string to have >=1 characters' }]),
);
}); });
it('should change the import paths', async () => { it('should change the import paths', async () => {
@@ -187,9 +181,7 @@ describe('/libraries', () => {
.send({ importPaths: [''] }); .send({ importPaths: [''] });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(errorDto.badRequest(['[importPaths] Array items must not be empty']));
errorDto.validationError([{ path: ['importPaths'], message: 'Array items must not be empty' }]),
);
}); });
it('should reject duplicate import paths', async () => { it('should reject duplicate import paths', async () => {
@@ -199,9 +191,7 @@ describe('/libraries', () => {
.send({ importPaths: ['/path', '/path'] }); .send({ importPaths: ['/path', '/path'] });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(errorDto.badRequest(['[importPaths] Array must have unique items']));
errorDto.validationError([{ path: ['importPaths'], message: 'Array must have unique items' }]),
);
}); });
it('should change the exclusion pattern', async () => { it('should change the exclusion pattern', async () => {
@@ -225,9 +215,7 @@ describe('/libraries', () => {
.send({ exclusionPatterns: ['**/*.jpg', '**/*.jpg'] }); .send({ exclusionPatterns: ['**/*.jpg', '**/*.jpg'] });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(errorDto.badRequest(['[exclusionPatterns] Array must have unique items']));
errorDto.validationError([{ path: ['exclusionPatterns'], message: 'Array must have unique items' }]),
);
}); });
it('should reject an empty exclusion pattern', async () => { it('should reject an empty exclusion pattern', async () => {
@@ -237,9 +225,7 @@ describe('/libraries', () => {
.send({ exclusionPatterns: [''] }); .send({ exclusionPatterns: [''] });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(errorDto.badRequest(['[exclusionPatterns] Array items must not be empty']));
errorDto.validationError([{ path: ['exclusionPatterns'], message: 'Array items must not be empty' }]),
);
}); });
}); });
+4 -12
View File
@@ -109,9 +109,7 @@ describe('/map', () => {
.get('/map/reverse-geocode?lon=123') .get('/map/reverse-geocode?lon=123')
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(errorDto.badRequest(['[lat] Invalid input: expected number, received NaN']));
errorDto.validationError([{ path: ['lat'], message: 'Invalid input: expected number, received NaN' }]),
);
}); });
it('should throw an error if a lat is not a number', async () => { it('should throw an error if a lat is not a number', async () => {
@@ -119,9 +117,7 @@ describe('/map', () => {
.get('/map/reverse-geocode?lat=abc&lon=123.456') .get('/map/reverse-geocode?lat=abc&lon=123.456')
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(errorDto.badRequest(['[lat] Invalid input: expected number, received NaN']));
errorDto.validationError([{ path: ['lat'], message: 'Invalid input: expected number, received NaN' }]),
);
}); });
it('should throw an error if a lat is out of range', async () => { it('should throw an error if a lat is out of range', async () => {
@@ -129,9 +125,7 @@ describe('/map', () => {
.get('/map/reverse-geocode?lat=91&lon=123.456') .get('/map/reverse-geocode?lat=91&lon=123.456')
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(errorDto.badRequest(['[lat] Too big: expected number to be <=90']));
errorDto.validationError([{ path: ['lat'], message: 'Too big: expected number to be <=90' }]),
);
}); });
it('should throw an error if a lon is not provided', async () => { it('should throw an error if a lon is not provided', async () => {
@@ -139,9 +133,7 @@ describe('/map', () => {
.get('/map/reverse-geocode?lat=75') .get('/map/reverse-geocode?lat=75')
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(errorDto.badRequest(['[lon] Invalid input: expected number, received NaN']));
errorDto.validationError([{ path: ['lon'], message: 'Invalid input: expected number, received NaN' }]),
);
}); });
const reverseGeocodeTestCases = [ const reverseGeocodeTestCases = [
+4 -16
View File
@@ -105,11 +105,7 @@ describe(`/oauth`, () => {
it(`should throw an error if a redirect uri is not provided`, async () => { it(`should throw an error if a redirect uri is not provided`, async () => {
const { status, body } = await request(app).post('/oauth/authorize').send({}); const { status, body } = await request(app).post('/oauth/authorize').send({});
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(errorDto.badRequest(['[redirectUri] Invalid input: expected string, received undefined']));
errorDto.validationError([
{ path: ['redirectUri'], message: 'Invalid input: expected string, received undefined' },
]),
);
}); });
it('should return a redirect uri', async () => { it('should return a redirect uri', async () => {
@@ -168,17 +164,13 @@ describe(`/oauth`, () => {
it(`should throw an error if a url is not provided`, async () => { it(`should throw an error if a url is not provided`, async () => {
const { status, body } = await request(app).post('/oauth/callback').send({}); const { status, body } = await request(app).post('/oauth/callback').send({});
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(errorDto.badRequest(['[url] Invalid input: expected string, received undefined']));
errorDto.validationError([{ path: ['url'], message: 'Invalid input: expected string, received undefined' }]),
);
}); });
it(`should throw an error if the url is empty`, async () => { it(`should throw an error if the url is empty`, async () => {
const { status, body } = await request(app).post('/oauth/callback').send({ url: '' }); const { status, body } = await request(app).post('/oauth/callback').send({ url: '' });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(errorDto.badRequest(['[url] Too small: expected string to have >=1 characters']));
errorDto.validationError([{ path: ['url'], message: 'Too small: expected string to have >=1 characters' }]),
);
}); });
it(`should throw an error if the state is not provided`, async () => { it(`should throw an error if the state is not provided`, async () => {
@@ -383,11 +375,7 @@ describe(`/oauth`, () => {
it(`should throw an error if the logout_token is not provided`, async () => { it(`should throw an error if the logout_token is not provided`, async () => {
const { status, body } = await request(app).post('/oauth/backchannel-logout').send({}); const { status, body } = await request(app).post('/oauth/backchannel-logout').send({});
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(errorDto.badRequest(['[logout_token] Invalid input: expected string, received undefined']));
errorDto.validationError([
{ path: ['logout_token'], message: 'Invalid input: expected string, received undefined' },
]),
);
}); });
it(`should throw an error if an invalid logout token is provided`, async () => { it(`should throw an error if an invalid logout token is provided`, async () => {
@@ -341,9 +341,7 @@ describe('/shared-links', () => {
.set('Authorization', `Bearer ${user1.accessToken}`); .set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(errorDto.badRequest());
errorDto.validationError([{ path: [], message: 'Invalid input: expected object, received undefined' }]),
);
}); });
it('should require an asset/album id', async () => { it('should require an asset/album id', async () => {
+2 -9
View File
@@ -41,9 +41,7 @@ describe('/stacks', () => {
.send({ assetIds: [asset.id] }); .send({ assetIds: [asset.id] });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(errorDto.badRequest());
errorDto.validationError([{ path: ['assetIds'], message: 'Too small: expected array to have >=2 items' }]),
);
}); });
it('should require a valid id', async () => { it('should require a valid id', async () => {
@@ -53,12 +51,7 @@ describe('/stacks', () => {
.send({ assetIds: [uuidDto.invalid, uuidDto.invalid] }); .send({ assetIds: [uuidDto.invalid, uuidDto.invalid] });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(errorDto.badRequest());
errorDto.validationError([
{ path: ['assetIds', 0], message: 'Invalid UUID' },
{ path: ['assetIds', 1], message: 'Invalid UUID' },
]),
);
}); });
it('should require access', async () => { it('should require access', async () => {
+2 -2
View File
@@ -309,7 +309,7 @@ describe('/tags', () => {
.get(`/tags/${uuidDto.invalid}`) .get(`/tags/${uuidDto.invalid}`)
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }])); expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID']));
}); });
it('should get tag details', async () => { it('should get tag details', async () => {
@@ -427,7 +427,7 @@ describe('/tags', () => {
.delete(`/tags/${uuidDto.invalid}`) .delete(`/tags/${uuidDto.invalid}`)
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }])); expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID']));
}); });
it('should delete a tag', async () => { it('should delete a tag', async () => {
@@ -108,20 +108,14 @@ describe('/admin/users', () => {
expect(body).toEqual(errorDto.forbidden); expect(body).toEqual(errorDto.forbidden);
}); });
for (const [key, message] of [ for (const key of ['password', 'email', 'name', 'quotaSizeInBytes', 'shouldChangePassword', 'notify']) {
['password', 'Invalid input: expected string, received null'],
['email', 'Invalid input: expected email, received object'],
['name', 'Invalid input: expected string, received null'],
['shouldChangePassword', 'Invalid input: expected boolean, received null'],
['notify', 'Invalid input: expected boolean, received null'],
] as const) {
it(`should not allow null ${key}`, async () => { it(`should not allow null ${key}`, async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.post(`/admin/users`) .post(`/admin/users`)
.set('Authorization', `Bearer ${admin.accessToken}`) .set('Authorization', `Bearer ${admin.accessToken}`)
.send({ ...createUserDto.user1, [key]: null }); .send({ ...createUserDto.user1, [key]: null });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(errorDto.validationError([{ path: [key], message }])); expect(body).toEqual(errorDto.badRequest());
}); });
} }
@@ -159,19 +153,14 @@ describe('/admin/users', () => {
expect(body).toEqual(errorDto.forbidden); expect(body).toEqual(errorDto.forbidden);
}); });
for (const [key, message] of [ for (const key of ['password', 'email', 'name', 'shouldChangePassword']) {
['password', 'Invalid input: expected string, received null'],
['email', 'Invalid input: expected email, received object'],
['name', 'Invalid input: expected string, received null'],
['shouldChangePassword', 'Invalid input: expected boolean, received null'],
] as const) {
it(`should not allow null ${key}`, async () => { it(`should not allow null ${key}`, async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.put(`/admin/users/${uuidDto.notFound}`) .put(`/admin/users/${uuidDto.notFound}`)
.set('Authorization', `Bearer ${admin.accessToken}`) .set('Authorization', `Bearer ${admin.accessToken}`)
.send({ [key]: null }); .send({ [key]: null });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(errorDto.validationError([{ path: [key], message }])); expect(body).toEqual(errorDto.badRequest());
}); });
} }
+2 -6
View File
@@ -179,9 +179,7 @@ describe('/users', () => {
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(
errorDto.validationError([ errorDto.badRequest(['[download.archiveSize] Invalid input: expected int, received number']),
{ path: ['download', 'archiveSize'], message: 'Invalid input: expected int, received number' },
]),
); );
}); });
@@ -209,9 +207,7 @@ describe('/users', () => {
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(
errorDto.validationError([ errorDto.badRequest(['[download.includeEmbeddedVideos] Invalid input: expected boolean, received number']),
{ path: ['download', 'includeEmbeddedVideos'], message: 'Invalid input: expected boolean, received number' },
]),
); );
}); });
+2 -2
View File
@@ -68,7 +68,7 @@ ENV LD_PRELOAD=/usr/lib/libmimalloc.so.2 \
RUN apt-get update && \ RUN apt-get update && \
# Pascal support was dropped in 9.11 # Pascal support was dropped in 9.11
apt-get install --no-install-recommends -yqq libcudnn9-cuda-12=9.10.2.21-1 tzdata && \ apt-get install --no-install-recommends -yqq libcudnn9-cuda-12=9.10.2.21-1 && \
apt-get clean && \ apt-get clean && \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*
@@ -112,7 +112,7 @@ ARG RKNN_TOOLKIT_VERSION="v2.3.0"
ENV LD_PRELOAD=/usr/lib/libmimalloc.so.2 \ ENV LD_PRELOAD=/usr/lib/libmimalloc.so.2 \
MACHINE_LEARNING_MODEL_ARENA=false MACHINE_LEARNING_MODEL_ARENA=false
ADD --chmod=644 --checksum=sha256:73993ed4b440460825f21611731564503cc1d5a0c123746477da6cd574f34885 "https://github.com/airockchip/rknn-toolkit2/raw/refs/tags/${RKNN_TOOLKIT_VERSION}/rknpu2/runtime/Linux/librknn_api/aarch64/librknnrt.so" /usr/lib/ ADD --checksum=sha256:73993ed4b440460825f21611731564503cc1d5a0c123746477da6cd574f34885 "https://github.com/airockchip/rknn-toolkit2/raw/refs/tags/${RKNN_TOOLKIT_VERSION}/rknpu2/runtime/Linux/librknn_api/aarch64/librknnrt.so" /usr/lib/
FROM prod-${DEVICE} AS prod FROM prod-${DEVICE} AS prod
+1 -1
View File
@@ -146,7 +146,7 @@ Class | Method | HTTP request | Description
*DeprecatedApi* | [**runQueueCommandLegacy**](doc//DeprecatedApi.md#runqueuecommandlegacy) | **PUT** /jobs/{name} | Run jobs *DeprecatedApi* | [**runQueueCommandLegacy**](doc//DeprecatedApi.md#runqueuecommandlegacy) | **PUT** /jobs/{name} | Run jobs
*DownloadApi* | [**downloadArchive**](doc//DownloadApi.md#downloadarchive) | **POST** /download/archive | Download asset archive *DownloadApi* | [**downloadArchive**](doc//DownloadApi.md#downloadarchive) | **POST** /download/archive | Download asset archive
*DownloadApi* | [**getDownloadInfo**](doc//DownloadApi.md#getdownloadinfo) | **POST** /download/info | Retrieve download information *DownloadApi* | [**getDownloadInfo**](doc//DownloadApi.md#getdownloadinfo) | **POST** /download/info | Retrieve download information
*DuplicatesApi* | [**deleteDuplicate**](doc//DuplicatesApi.md#deleteduplicate) | **DELETE** /duplicates/{id} | Dismiss a duplicate group *DuplicatesApi* | [**deleteDuplicate**](doc//DuplicatesApi.md#deleteduplicate) | **DELETE** /duplicates/{id} | Delete a duplicate
*DuplicatesApi* | [**deleteDuplicates**](doc//DuplicatesApi.md#deleteduplicates) | **DELETE** /duplicates | Delete duplicates *DuplicatesApi* | [**deleteDuplicates**](doc//DuplicatesApi.md#deleteduplicates) | **DELETE** /duplicates | Delete duplicates
*DuplicatesApi* | [**getAssetDuplicates**](doc//DuplicatesApi.md#getassetduplicates) | **GET** /duplicates | Retrieve duplicates *DuplicatesApi* | [**getAssetDuplicates**](doc//DuplicatesApi.md#getassetduplicates) | **GET** /duplicates | Retrieve duplicates
*DuplicatesApi* | [**resolveDuplicates**](doc//DuplicatesApi.md#resolveduplicates) | **POST** /duplicates/resolve | Resolve duplicate groups *DuplicatesApi* | [**resolveDuplicates**](doc//DuplicatesApi.md#resolveduplicates) | **POST** /duplicates/resolve | Resolve duplicate groups
+4 -4
View File
@@ -16,9 +16,9 @@ class DuplicatesApi {
final ApiClient apiClient; final ApiClient apiClient;
/// Dismiss a duplicate group /// Delete a duplicate
/// ///
/// Dismiss a duplicate group by its ID, unlinking all assets in the group without deleting them. /// Delete a single duplicate asset specified by its ID.
/// ///
/// Note: This method returns the HTTP [Response]. /// Note: This method returns the HTTP [Response].
/// ///
@@ -51,9 +51,9 @@ class DuplicatesApi {
); );
} }
/// Dismiss a duplicate group /// Delete a duplicate
/// ///
/// Dismiss a duplicate group by its ID, unlinking all assets in the group without deleting them. /// Delete a single duplicate asset specified by its ID.
/// ///
/// Parameters: /// Parameters:
/// ///
+2 -2
View File
@@ -5172,7 +5172,7 @@
}, },
"/duplicates/{id}": { "/duplicates/{id}": {
"delete": { "delete": {
"description": "Dismiss a duplicate group by its ID, unlinking all assets in the group without deleting them.", "description": "Delete a single duplicate asset specified by its ID.",
"operationId": "deleteDuplicate", "operationId": "deleteDuplicate",
"parameters": [ "parameters": [
{ {
@@ -5202,7 +5202,7 @@
"api_key": [] "api_key": []
} }
], ],
"summary": "Dismiss a duplicate group", "summary": "Delete a duplicate",
"tags": [ "tags": [
"Duplicates" "Duplicates"
], ],
+1 -1
View File
@@ -4480,7 +4480,7 @@ export function resolveDuplicates({ duplicateResolveDto }: {
}))); })));
} }
/** /**
* Dismiss a duplicate group * Delete a duplicate
*/ */
export function deleteDuplicate({ id }: { export function deleteDuplicate({ id }: {
id: string; id: string;
@@ -1,16 +1,9 @@
import { HttpError } from '@oazapfts/runtime'; import { HttpError } from '@oazapfts/runtime';
export interface ApiValidationError {
code: string;
path: (string | number)[];
message: string;
}
export interface ApiExceptionResponse { export interface ApiExceptionResponse {
message: string; message: string;
error?: string; error?: string;
statusCode: number; statusCode: number;
errors?: ApiValidationError[];
} }
export interface ApiHttpError extends HttpError { export interface ApiHttpError extends HttpError {
@@ -28,16 +28,14 @@ describe(ActivityController.name, () => {
const { status, body } = await request(ctx.getHttpServer()).get('/activities'); const { status, body } = await request(ctx.getHttpServer()).get('/activities');
expect(status).toEqual(400); expect(status).toEqual(400);
expect(body).toEqual( expect(body).toEqual(
factory.responses.validationError([ factory.responses.badRequest(['[albumId] Invalid input: expected string, received undefined']),
{ path: ['albumId'], message: 'Invalid input: expected string, received undefined' },
]),
); );
}); });
it('should reject an invalid albumId', async () => { it('should reject an invalid albumId', async () => {
const { status, body } = await request(ctx.getHttpServer()).get('/activities').query({ albumId: '123' }); const { status, body } = await request(ctx.getHttpServer()).get('/activities').query({ albumId: '123' });
expect(status).toEqual(400); expect(status).toEqual(400);
expect(body).toEqual(factory.responses.validationError([{ path: ['albumId'], message: 'Invalid UUID' }])); expect(body).toEqual(factory.responses.badRequest(['[albumId] Invalid UUID']));
}); });
it('should reject an invalid assetId', async () => { it('should reject an invalid assetId', async () => {
@@ -45,7 +43,7 @@ describe(ActivityController.name, () => {
.get('/activities') .get('/activities')
.query({ albumId: factory.uuid(), assetId: '123' }); .query({ albumId: factory.uuid(), assetId: '123' });
expect(status).toEqual(400); expect(status).toEqual(400);
expect(body).toEqual(factory.responses.validationError([{ path: ['assetId'], message: 'Invalid UUID' }])); expect(body).toEqual(factory.responses.badRequest(['[assetId] Invalid UUID']));
}); });
}); });
@@ -60,7 +58,7 @@ describe(ActivityController.name, () => {
.post('/activities') .post('/activities')
.send({ albumId: '123', type: 'like' }); .send({ albumId: '123', type: 'like' });
expect(status).toEqual(400); expect(status).toEqual(400);
expect(body).toEqual(factory.responses.validationError([{ path: ['albumId'], message: 'Invalid UUID' }])); expect(body).toEqual(factory.responses.badRequest(['[albumId] Invalid UUID']));
}); });
it('should require a comment when type is comment', async () => { it('should require a comment when type is comment', async () => {
@@ -68,11 +66,7 @@ describe(ActivityController.name, () => {
.post('/activities') .post('/activities')
.send({ albumId: factory.uuid(), type: 'comment', comment: null }); .send({ albumId: factory.uuid(), type: 'comment', comment: null });
expect(status).toEqual(400); expect(status).toEqual(400);
expect(body).toEqual( expect(body).toEqual(factory.responses.badRequest(['[comment] Invalid input: expected string, received null']));
factory.responses.validationError([
{ path: ['comment'], message: 'Invalid input: expected string, received null' },
]),
);
}); });
}); });
@@ -85,7 +79,7 @@ describe(ActivityController.name, () => {
it('should require a valid uuid', async () => { it('should require a valid uuid', async () => {
const { status, body } = await request(ctx.getHttpServer()).delete(`/activities/123`); const { status, body } = await request(ctx.getHttpServer()).delete(`/activities/123`);
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }])); expect(body).toEqual(factory.responses.badRequest(['[id] Invalid UUID']));
}); });
}); });
}); });
@@ -27,17 +27,13 @@ describe(AlbumController.name, () => {
it('should reject an invalid shared param', async () => { it('should reject an invalid shared param', async () => {
const { status, body } = await request(ctx.getHttpServer()).get('/albums?shared=invalid'); const { status, body } = await request(ctx.getHttpServer()).get('/albums?shared=invalid');
expect(status).toEqual(400); expect(status).toEqual(400);
expect(body).toEqual( expect(body).toEqual(factory.responses.badRequest(['[shared] Invalid option: expected one of "true"|"false"']));
factory.responses.validationError([
{ path: ['shared'], message: 'Invalid option: expected one of "true"|"false"' },
]),
);
}); });
it('should reject an invalid assetId param', async () => { it('should reject an invalid assetId param', async () => {
const { status, body } = await request(ctx.getHttpServer()).get('/albums?assetId=invalid'); const { status, body } = await request(ctx.getHttpServer()).get('/albums?assetId=invalid');
expect(status).toEqual(400); expect(status).toEqual(400);
expect(body).toEqual(factory.responses.validationError([{ path: ['assetId'], message: 'Invalid UUID' }])); expect(body).toEqual(factory.responses.badRequest(['[assetId] Invalid UUID']));
}); });
}); });
@@ -49,7 +49,7 @@ describe(ApiKeyController.name, () => {
it('should require a valid uuid', async () => { it('should require a valid uuid', async () => {
const { status, body } = await request(ctx.getHttpServer()).get(`/api-keys/123`); const { status, body } = await request(ctx.getHttpServer()).get(`/api-keys/123`);
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }])); expect(body).toEqual(factory.responses.badRequest(['[id] Invalid UUID']));
}); });
}); });
@@ -64,7 +64,7 @@ describe(ApiKeyController.name, () => {
.put(`/api-keys/123`) .put(`/api-keys/123`)
.send({ name: 'new name', permissions: [Permission.All] }); .send({ name: 'new name', permissions: [Permission.All] });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }])); expect(body).toEqual(factory.responses.badRequest(['[id] Invalid UUID']));
}); });
it('should allow updating just the name', async () => { it('should allow updating just the name', async () => {
@@ -84,7 +84,7 @@ describe(ApiKeyController.name, () => {
it('should require a valid uuid', async () => { it('should require a valid uuid', async () => {
const { status, body } = await request(ctx.getHttpServer()).delete(`/api-keys/123`); const { status, body } = await request(ctx.getHttpServer()).delete(`/api-keys/123`);
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }])); expect(body).toEqual(factory.responses.badRequest(['[id] Invalid UUID']));
}); });
}); });
}); });
@@ -80,9 +80,7 @@ describe(AssetMediaController.name, () => {
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(
factory.responses.validationError([ factory.responses.badRequest(['[metadata] Invalid input: expected JSON string, received string']),
{ path: ['metadata'], message: 'Invalid input: expected JSON string, received string' },
]),
); );
}); });
@@ -93,8 +91,8 @@ describe(AssetMediaController.name, () => {
.field({ ...makeUploadDto({ omit: 'fileCreatedAt' }) }); .field({ ...makeUploadDto({ omit: 'fileCreatedAt' }) });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(
factory.responses.validationError([ factory.responses.badRequest([
{ path: ['fileCreatedAt'], message: 'Invalid input: expected ISO 8601 datetime string, received undefined' }, '[fileCreatedAt] Invalid input: expected ISO 8601 datetime string, received undefined',
]), ]),
); );
}); });
@@ -106,8 +104,8 @@ describe(AssetMediaController.name, () => {
.field(makeUploadDto({ omit: 'fileModifiedAt' })); .field(makeUploadDto({ omit: 'fileModifiedAt' }));
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(
factory.responses.validationError([ factory.responses.badRequest([
{ path: ['fileModifiedAt'], message: 'Invalid input: expected ISO 8601 datetime string, received undefined' }, '[fileModifiedAt] Invalid input: expected ISO 8601 datetime string, received undefined',
]), ]),
); );
}); });
@@ -119,9 +117,7 @@ describe(AssetMediaController.name, () => {
.field({ ...makeUploadDto(), isFavorite: 'not-a-boolean' }); .field({ ...makeUploadDto(), isFavorite: 'not-a-boolean' });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(
factory.responses.validationError([ factory.responses.badRequest(['[isFavorite] Invalid option: expected one of "true"|"false"']),
{ path: ['isFavorite'], message: 'Invalid option: expected one of "true"|"false"' },
]),
); );
}); });
@@ -132,9 +128,7 @@ describe(AssetMediaController.name, () => {
.field({ ...makeUploadDto(), visibility: 'not-an-option' }); .field({ ...makeUploadDto(), visibility: 'not-an-option' });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(
factory.responses.validationError([ factory.responses.badRequest([expect.stringContaining('[visibility] Invalid option: expected one of')]),
{ path: ['visibility'], message: expect.stringContaining('Invalid option: expected one of') },
]),
); );
}); });
+51 -87
View File
@@ -31,7 +31,7 @@ describe(AssetController.name, () => {
.send({ ids: ['123'] }); .send({ ids: ['123'] });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(factory.responses.validationError([{ path: ['ids', 0], message: 'Invalid UUID' }])); expect(body).toEqual(factory.responses.badRequest(['[ids.0] Invalid UUID']));
}); });
it('should require duplicateId to be a string', async () => { it('should require duplicateId to be a string', async () => {
@@ -42,9 +42,7 @@ describe(AssetController.name, () => {
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(
factory.responses.validationError([ factory.responses.badRequest(['[duplicateId] Invalid input: expected string, received boolean']),
{ path: ['duplicateId'], message: 'Invalid input: expected string, received boolean' },
]),
); );
}); });
@@ -72,7 +70,7 @@ describe(AssetController.name, () => {
.send({ ids: ['123'] }); .send({ ids: ['123'] });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(factory.responses.validationError([{ path: ['ids', 0], message: 'Invalid UUID' }])); expect(body).toEqual(factory.responses.badRequest(['[ids.0] Invalid UUID']));
}); });
}); });
@@ -85,7 +83,7 @@ describe(AssetController.name, () => {
it('should require a valid id', async () => { it('should require a valid id', async () => {
const { status, body } = await request(ctx.getHttpServer()).get(`/assets/123`); const { status, body } = await request(ctx.getHttpServer()).get(`/assets/123`);
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }])); expect(body).toEqual(factory.responses.badRequest(['[id] Invalid UUID']));
}); });
}); });
@@ -99,10 +97,12 @@ describe(AssetController.name, () => {
const { status, body } = await request(ctx.getHttpServer()).put('/assets/copy').send({}); const { status, body } = await request(ctx.getHttpServer()).put('/assets/copy').send({});
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(
factory.responses.validationError([ factory.responses.badRequest(
{ path: ['sourceId'], message: 'Invalid input: expected string, received undefined' }, expect.arrayContaining([
{ path: ['targetId'], message: 'Invalid input: expected string, received undefined' }, '[sourceId] Invalid input: expected string, received undefined',
]), '[targetId] Invalid input: expected string, received undefined',
]),
),
); );
}); });
@@ -125,9 +125,7 @@ describe(AssetController.name, () => {
.put('/assets/metadata') .put('/assets/metadata')
.send({ items: [{ assetId: '123', key: 'test', value: {} }] }); .send({ items: [{ assetId: '123', key: 'test', value: {} }] });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['[items.0.assetId] Invalid UUID'])));
factory.responses.validationError([{ path: ['items', 0, 'assetId'], message: 'Invalid UUID' }]),
);
}); });
it('should require a key', async () => { it('should require a key', async () => {
@@ -136,9 +134,9 @@ describe(AssetController.name, () => {
.send({ items: [{ assetId: factory.uuid(), value: {} }] }); .send({ items: [{ assetId: factory.uuid(), value: {} }] });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(
factory.responses.validationError([ factory.responses.badRequest(
{ path: ['items', 0, 'key'], message: 'Invalid input: expected string, received undefined' }, expect.arrayContaining(['[items.0.key] Invalid input: expected string, received undefined']),
]), ),
); );
}); });
@@ -161,9 +159,7 @@ describe(AssetController.name, () => {
.delete('/assets/metadata') .delete('/assets/metadata')
.send({ items: [{ assetId: '123', key: 'test' }] }); .send({ items: [{ assetId: '123', key: 'test' }] });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['[items.0.assetId] Invalid UUID'])));
factory.responses.validationError([{ path: ['items', 0, 'assetId'], message: 'Invalid UUID' }]),
);
}); });
it('should require a key', async () => { it('should require a key', async () => {
@@ -172,9 +168,9 @@ describe(AssetController.name, () => {
.send({ items: [{ assetId: factory.uuid() }] }); .send({ items: [{ assetId: factory.uuid() }] });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(
factory.responses.validationError([ factory.responses.badRequest(
{ path: ['items', 0, 'key'], message: 'Invalid input: expected string, received undefined' }, expect.arrayContaining(['[items.0.key] Invalid input: expected string, received undefined']),
]), ),
); );
}); });
@@ -195,56 +191,33 @@ describe(AssetController.name, () => {
it('should require a valid id', async () => { it('should require a valid id', async () => {
const { status, body } = await request(ctx.getHttpServer()).put(`/assets/123`); const { status, body } = await request(ctx.getHttpServer()).put(`/assets/123`);
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(factory.responses.badRequest(['Invalid input: expected object, received undefined']));
factory.responses.validationError([
{ path: [], message: 'Invalid input: expected object, received undefined' },
]),
);
}); });
it('should reject invalid gps coordinates', async () => { it('should reject invalid gps coordinates', async () => {
for (const [test, errors] of [ for (const test of [
[{ latitude: 12 }, [{ path: [], message: 'Latitude and longitude must be provided together' }]], { latitude: 12 },
[{ longitude: 12 }, [{ path: [], message: 'Latitude and longitude must be provided together' }]], { longitude: 12 },
[ { latitude: 12, longitude: 'abc' },
{ latitude: 12, longitude: 'abc' }, { latitude: 'abc', longitude: 12 },
[{ path: ['longitude'], message: 'Invalid input: expected number, received string' }], { latitude: null, longitude: 12 },
], { latitude: 12, longitude: null },
[ { latitude: 91, longitude: 12 },
{ latitude: 'abc', longitude: 12 }, { latitude: -91, longitude: 12 },
[{ path: ['latitude'], message: 'Invalid input: expected number, received string' }], { latitude: 12, longitude: -181 },
], { latitude: 12, longitude: 181 },
[ ]) {
{ latitude: null, longitude: 12 },
[{ path: ['latitude'], message: 'Invalid input: expected number, received null' }],
],
[
{ latitude: 12, longitude: null },
[{ path: ['longitude'], message: 'Invalid input: expected number, received null' }],
],
[{ latitude: 91, longitude: 12 }, [{ path: ['latitude'], message: 'Too big: expected number to be <=90' }]],
[{ latitude: -91, longitude: 12 }, [{ path: ['latitude'], message: 'Too small: expected number to be >=-90' }]],
[
{ latitude: 12, longitude: -181 },
[{ path: ['longitude'], message: 'Too small: expected number to be >=-180' }],
],
[{ latitude: 12, longitude: 181 }, [{ path: ['longitude'], message: 'Too big: expected number to be <=180' }]],
] as const) {
const { status, body } = await request(ctx.getHttpServer()).put(`/assets/${factory.uuid()}`).send(test); const { status, body } = await request(ctx.getHttpServer()).put(`/assets/${factory.uuid()}`).send(test);
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(factory.responses.validationError(errors)); expect(body).toEqual(factory.responses.badRequest());
} }
}); });
it('should reject invalid rating', async () => { it('should reject invalid rating', async () => {
for (const [test, errors] of [ for (const test of [{ rating: 7 }, { rating: 3.5 }, { rating: -2 }]) {
[{ rating: 7 }, [{ path: ['rating'], message: 'Too big: expected number to be <=5' }]],
[{ rating: 3.5 }, [{ path: ['rating'], message: 'Invalid input: expected int, received number' }]],
[{ rating: -2 }, [{ path: ['rating'], message: 'Too small: expected number to be >=-1' }]],
] as const) {
const { status, body } = await request(ctx.getHttpServer()).put(`/assets/${factory.uuid()}`).send(test); const { status, body } = await request(ctx.getHttpServer()).put(`/assets/${factory.uuid()}`).send(test);
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(factory.responses.validationError(errors)); expect(body).toEqual(factory.responses.badRequest());
} }
}); });
@@ -288,17 +261,13 @@ describe(AssetController.name, () => {
it('should require a valid id', async () => { it('should require a valid id', async () => {
const { status, body } = await request(ctx.getHttpServer()).put(`/assets/123/metadata`).send({ items: [] }); const { status, body } = await request(ctx.getHttpServer()).put(`/assets/123/metadata`).send({ items: [] });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }])); expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['[id] Invalid UUID'])));
}); });
it('should require items to be an array', async () => { it('should require items to be an array', async () => {
const { status, body } = await request(ctx.getHttpServer()).put(`/assets/${factory.uuid()}/metadata`).send({}); const { status, body } = await request(ctx.getHttpServer()).put(`/assets/${factory.uuid()}/metadata`).send({});
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(factory.responses.badRequest(['[items] Invalid input: expected array, received undefined']));
factory.responses.validationError([
{ path: ['items'], message: 'Invalid input: expected array, received undefined' },
]),
);
}); });
it('should require each item to have a valid key', async () => { it('should require each item to have a valid key', async () => {
@@ -307,9 +276,7 @@ describe(AssetController.name, () => {
.send({ items: [{ value: { some: 'value' } }] }); .send({ items: [{ value: { some: 'value' } }] });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(
factory.responses.validationError([ factory.responses.badRequest(['[items.0.key] Invalid input: expected string, received undefined']),
{ path: ['items', 0, 'key'], message: 'Invalid input: expected string, received undefined' },
]),
); );
}); });
@@ -319,9 +286,9 @@ describe(AssetController.name, () => {
.send({ items: [{ key: 'mobile-app', value: null }] }); .send({ items: [{ key: 'mobile-app', value: null }] });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(
factory.responses.validationError([ factory.responses.badRequest(
{ path: ['items', 0, 'value'], message: 'Invalid input: expected record, received null' }, expect.arrayContaining(['[items.0.value] Invalid input: expected record, received null']),
]), ),
); );
}); });
@@ -359,7 +326,7 @@ describe(AssetController.name, () => {
it('should require a valid id', async () => { it('should require a valid id', async () => {
const { status, body } = await request(ctx.getHttpServer()).get(`/assets/123/metadata/mobile-app`); const { status, body } = await request(ctx.getHttpServer()).get(`/assets/123/metadata/mobile-app`);
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }])); expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['[id] Invalid UUID'])));
}); });
}); });
@@ -409,7 +376,7 @@ describe(AssetController.name, () => {
}); });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }])); expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['[id] Invalid UUID'])));
}); });
it('should check the action and parameters discriminator', async () => { it('should check the action and parameters discriminator', async () => {
@@ -431,12 +398,13 @@ describe(AssetController.name, () => {
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(
factory.responses.validationError([ factory.responses.badRequest(
{ expect.arrayContaining([
path: ['edits', 0, 'parameters'], expect.stringContaining(
message: expect.stringContaining("Invalid parameters for action 'rotate', expecting keys: angle"), "[edits.0.parameters] Invalid parameters for action 'rotate', expecting keys: angle",
}, ),
]), ]),
),
); );
}); });
@@ -445,11 +413,7 @@ describe(AssetController.name, () => {
.put(`/assets/${factory.uuid()}/edits`) .put(`/assets/${factory.uuid()}/edits`)
.send({ edits: [] }); .send({ edits: [] });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(factory.responses.badRequest(['[edits] Too small: expected array to have >=1 items']));
factory.responses.validationError([
{ path: ['edits'], message: 'Too small: expected array to have >=1 items' },
]),
);
}); });
}); });
@@ -462,7 +426,7 @@ describe(AssetController.name, () => {
it('should require a valid id', async () => { it('should require a valid id', async () => {
const { status, body } = await request(ctx.getHttpServer()).delete(`/assets/123/metadata/mobile-app`); const { status, body } = await request(ctx.getHttpServer()).delete(`/assets/123/metadata/mobile-app`);
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }])); expect(body).toEqual(factory.responses.badRequest(['[id] Invalid UUID']));
}); });
}); });
}); });
+13 -41
View File
@@ -28,27 +28,19 @@ describe(AuthController.name, () => {
it('should require an email address', async () => { it('should require an email address', async () => {
const { status, body } = await request(ctx.getHttpServer()).post('/auth/admin-sign-up').send({ name, password }); const { status, body } = await request(ctx.getHttpServer()).post('/auth/admin-sign-up').send({ name, password });
expect(status).toEqual(400); expect(status).toEqual(400);
expect(body).toEqual( expect(body).toEqual(errorDto.badRequest());
errorDto.validationError([{ path: ['email'], message: 'Invalid input: expected email, received undefined' }]),
);
}); });
it('should require a password', async () => { it('should require a password', async () => {
const { status, body } = await request(ctx.getHttpServer()).post('/auth/admin-sign-up').send({ name, email }); const { status, body } = await request(ctx.getHttpServer()).post('/auth/admin-sign-up').send({ name, email });
expect(status).toEqual(400); expect(status).toEqual(400);
expect(body).toEqual( expect(body).toEqual(errorDto.badRequest());
errorDto.validationError([
{ path: ['password'], message: 'Invalid input: expected string, received undefined' },
]),
);
}); });
it('should require a name', async () => { it('should require a name', async () => {
const { status, body } = await request(ctx.getHttpServer()).post('/auth/admin-sign-up').send({ email, password }); const { status, body } = await request(ctx.getHttpServer()).post('/auth/admin-sign-up').send({ email, password });
expect(status).toEqual(400); expect(status).toEqual(400);
expect(body).toEqual( expect(body).toEqual(errorDto.badRequest());
errorDto.validationError([{ path: ['name'], message: 'Invalid input: expected string, received undefined' }]),
);
}); });
it('should require a valid email', async () => { it('should require a valid email', async () => {
@@ -56,9 +48,7 @@ describe(AuthController.name, () => {
.post('/auth/admin-sign-up') .post('/auth/admin-sign-up')
.send({ name, email: 'immich', password }); .send({ name, email: 'immich', password });
expect(status).toEqual(400); expect(status).toEqual(400);
expect(body).toEqual( expect(body).toEqual(errorDto.badRequest());
errorDto.validationError([{ path: ['email'], message: 'Invalid input: expected email, received string' }]),
);
}); });
it('should transform email to lower case', async () => { it('should transform email to lower case', async () => {
@@ -83,9 +73,9 @@ describe(AuthController.name, () => {
const { status, body } = await request(ctx.getHttpServer()).post('/auth/login').send({ name: 'admin' }); const { status, body } = await request(ctx.getHttpServer()).post('/auth/login').send({ name: 'admin' });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(
errorDto.validationError([ errorDto.badRequest([
{ path: ['email'], message: 'Invalid input: expected email, received undefined' }, '[email] Invalid input: expected email, received undefined',
{ path: ['password'], message: 'Invalid input: expected string, received undefined' }, '[password] Invalid input: expected string, received undefined',
]), ]),
); );
}); });
@@ -95,9 +85,7 @@ describe(AuthController.name, () => {
.post('/auth/login') .post('/auth/login')
.send({ name: 'admin', email: null, password: 'password' }); .send({ name: 'admin', email: null, password: 'password' });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(errorDto.badRequest(['[email] Invalid input: expected email, received object']));
errorDto.validationError([{ path: ['email'], message: 'Invalid input: expected email, received object' }]),
);
}); });
it(`should not allow null password`, async () => { it(`should not allow null password`, async () => {
@@ -105,9 +93,7 @@ describe(AuthController.name, () => {
.post('/auth/login') .post('/auth/login')
.send({ name: 'admin', email: 'admin@immich.cloud', password: null }); .send({ name: 'admin', email: 'admin@immich.cloud', password: null });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(errorDto.badRequest(['[password] Invalid input: expected string, received null']));
errorDto.validationError([{ path: ['password'], message: 'Invalid input: expected string, received null' }]),
);
}); });
it('should reject an invalid email', async () => { it('should reject an invalid email', async () => {
@@ -118,9 +104,7 @@ describe(AuthController.name, () => {
.send({ name: 'admin', email: [], password: 'password' }); .send({ name: 'admin', email: [], password: 'password' });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(errorDto.badRequest(['[email] Invalid input: expected email, received object']));
errorDto.validationError([{ path: ['email'], message: 'Invalid input: expected email, received object' }]),
);
}); });
it('should transform the email to all lowercase', async () => { it('should transform the email to all lowercase', async () => {
@@ -211,31 +195,19 @@ describe(AuthController.name, () => {
it('should reject 5 digits', async () => { it('should reject 5 digits', async () => {
const { status, body } = await request(ctx.getHttpServer()).post('/auth/pin-code').send({ pinCode: '12345' }); const { status, body } = await request(ctx.getHttpServer()).post('/auth/pin-code').send({ pinCode: '12345' });
expect(status).toEqual(400); expect(status).toEqual(400);
expect(body).toEqual( expect(body).toEqual(errorDto.badRequest([String.raw`[pinCode] Invalid string: must match pattern /^\d{6}$/`]));
errorDto.validationError([
{ path: ['pinCode'], message: String.raw`Invalid string: must match pattern /^\d{6}$/` },
]),
);
}); });
it('should reject 7 digits', async () => { it('should reject 7 digits', async () => {
const { status, body } = await request(ctx.getHttpServer()).post('/auth/pin-code').send({ pinCode: '1234567' }); const { status, body } = await request(ctx.getHttpServer()).post('/auth/pin-code').send({ pinCode: '1234567' });
expect(status).toEqual(400); expect(status).toEqual(400);
expect(body).toEqual( expect(body).toEqual(errorDto.badRequest([String.raw`[pinCode] Invalid string: must match pattern /^\d{6}$/`]));
errorDto.validationError([
{ path: ['pinCode'], message: String.raw`Invalid string: must match pattern /^\d{6}$/` },
]),
);
}); });
it('should reject non-numbers', async () => { it('should reject non-numbers', async () => {
const { status, body } = await request(ctx.getHttpServer()).post('/auth/pin-code').send({ pinCode: 'A12345' }); const { status, body } = await request(ctx.getHttpServer()).post('/auth/pin-code').send({ pinCode: 'A12345' });
expect(status).toEqual(400); expect(status).toEqual(400);
expect(body).toEqual( expect(body).toEqual(errorDto.badRequest([String.raw`[pinCode] Invalid string: must match pattern /^\d{6}$/`]));
errorDto.validationError([
{ path: ['pinCode'], message: String.raw`Invalid string: must match pattern /^\d{6}$/` },
]),
);
}); });
}); });
@@ -41,7 +41,7 @@ describe(DuplicateController.name, () => {
it('should require a valid uuid', async () => { it('should require a valid uuid', async () => {
const { status, body } = await request(ctx.getHttpServer()).delete(`/duplicates/123`); const { status, body } = await request(ctx.getHttpServer()).delete(`/duplicates/123`);
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }])); expect(body).toEqual(factory.responses.badRequest(['[id] Invalid UUID']));
}); });
}); });
}); });
@@ -41,8 +41,8 @@ export class DuplicateController {
@Authenticated({ permission: Permission.DuplicateDelete }) @Authenticated({ permission: Permission.DuplicateDelete })
@HttpCode(HttpStatus.NO_CONTENT) @HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({ @Endpoint({
summary: 'Dismiss a duplicate group', summary: 'Delete a duplicate',
description: 'Dismiss a duplicate group by its ID, unlinking all assets in the group without deleting them.', description: 'Delete a single duplicate asset specified by its ID.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
}) })
deleteDuplicate(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> { deleteDuplicate(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
@@ -31,9 +31,7 @@ describe(MaintenanceController.name, () => {
}); });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(
errorDto.validationError([ errorDto.badRequest(['[restoreBackupFilename] Backup filename is required when action is restore_database']),
{ path: ['restoreBackupFilename'], message: 'Backup filename is required when action is restore_database' },
]),
); );
expect(ctx.authenticate).toHaveBeenCalled(); expect(ctx.authenticate).toHaveBeenCalled();
}); });
@@ -47,11 +47,7 @@ describe(MemoryController.name, () => {
}); });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(errorDto.badRequest(['[data.year] Invalid input: expected number, received undefined']));
errorDto.validationError([
{ path: ['data', 'year'], message: 'Invalid input: expected number, received undefined' },
]),
);
}); });
it('should accept showAt and hideAt', async () => { it('should accept showAt and hideAt', async () => {
@@ -85,7 +81,7 @@ describe(MemoryController.name, () => {
it('should require a valid id', async () => { it('should require a valid id', async () => {
const { status, body } = await request(ctx.getHttpServer()).get(`/memories/invalid`); const { status, body } = await request(ctx.getHttpServer()).get(`/memories/invalid`);
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }])); expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID']));
}); });
}); });
@@ -98,15 +94,13 @@ describe(MemoryController.name, () => {
it('should require a valid id', async () => { it('should require a valid id', async () => {
const { status, body } = await request(ctx.getHttpServer()).put(`/memories/invalid`); const { status, body } = await request(ctx.getHttpServer()).put(`/memories/invalid`);
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(errorDto.badRequest(['Invalid input: expected object, received undefined']));
errorDto.validationError([{ path: [], message: 'Invalid input: expected object, received undefined' }]),
);
}); });
it('should require at least one field', async () => { it('should require at least one field', async () => {
const { status, body } = await request(ctx.getHttpServer()).put(`/memories/${factory.uuid()}`).send({}); const { status, body } = await request(ctx.getHttpServer()).put(`/memories/${factory.uuid()}`).send({});
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(errorDto.validationError([{ path: [], message: 'At least one field must be provided' }])); expect(body).toEqual(errorDto.badRequest(['At least one field must be provided']));
}); });
}); });
@@ -126,7 +120,7 @@ describe(MemoryController.name, () => {
it('should require a valid id', async () => { it('should require a valid id', async () => {
const { status, body } = await request(ctx.getHttpServer()).put(`/memories/invalid/assets`).send({ ids: [] }); const { status, body } = await request(ctx.getHttpServer()).put(`/memories/invalid/assets`).send({ ids: [] });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }])); expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID']));
}); });
it('should require a valid asset id', async () => { it('should require a valid asset id', async () => {
@@ -134,7 +128,7 @@ describe(MemoryController.name, () => {
.put(`/memories/${factory.uuid()}/assets`) .put(`/memories/${factory.uuid()}/assets`)
.send({ ids: ['invalid'] }); .send({ ids: ['invalid'] });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(errorDto.validationError([{ path: ['ids', 0], message: 'Invalid UUID' }])); expect(body).toEqual(errorDto.badRequest(['[ids.0] Invalid UUID']));
}); });
}); });
@@ -147,7 +141,7 @@ describe(MemoryController.name, () => {
it('should require a valid id', async () => { it('should require a valid id', async () => {
const { status, body } = await request(ctx.getHttpServer()).delete(`/memories/invalid/assets`); const { status, body } = await request(ctx.getHttpServer()).delete(`/memories/invalid/assets`);
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }])); expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID']));
}); });
it('should require a valid asset id', async () => { it('should require a valid asset id', async () => {
@@ -155,7 +149,7 @@ describe(MemoryController.name, () => {
.delete(`/memories/${factory.uuid()}/assets`) .delete(`/memories/${factory.uuid()}/assets`)
.send({ ids: ['invalid'] }); .send({ ids: ['invalid'] });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(errorDto.validationError([{ path: ['ids', 0], message: 'Invalid UUID' }])); expect(body).toEqual(errorDto.badRequest(['[ids.0] Invalid UUID']));
}); });
}); });
}); });
@@ -31,11 +31,7 @@ describe(NotificationController.name, () => {
.query({ level: 'invalid' }) .query({ level: 'invalid' })
.set('Authorization', `Bearer token`); .set('Authorization', `Bearer token`);
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(errorDto.badRequest([expect.stringContaining('[level] Invalid option: expected one of')]));
errorDto.validationError([
{ path: ['level'], message: expect.stringContaining('Invalid option: expected one of') },
]),
);
}); });
}); });
@@ -49,9 +45,7 @@ describe(NotificationController.name, () => {
it('should require a list', async () => { it('should require a list', async () => {
const { status, body } = await request(ctx.getHttpServer()).put(`/notifications`).send({ ids: true }); const { status, body } = await request(ctx.getHttpServer()).put(`/notifications`).send({ ids: true });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(errorDto.badRequest(['[ids] Invalid input: expected array, received boolean']));
errorDto.validationError([{ path: ['ids'], message: 'Invalid input: expected array, received boolean' }]),
);
}); });
it('should require uuids', async () => { it('should require uuids', async () => {
@@ -59,9 +53,7 @@ describe(NotificationController.name, () => {
.put(`/notifications`) .put(`/notifications`)
.send({ ids: [true] }); .send({ ids: [true] });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(errorDto.badRequest(['[ids.0] Invalid input: expected string, received boolean']));
errorDto.validationError([{ path: ['ids', 0], message: 'Invalid input: expected string, received boolean' }]),
);
}); });
it('should accept valid uuids', async () => { it('should accept valid uuids', async () => {
@@ -83,7 +75,7 @@ describe(NotificationController.name, () => {
it('should require a valid uuid', async () => { it('should require a valid uuid', async () => {
const { status, body } = await request(ctx.getHttpServer()).get(`/notifications/123`); const { status, body } = await request(ctx.getHttpServer()).get(`/notifications/123`);
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }])); expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID']));
}); });
}); });
@@ -33,9 +33,7 @@ describe(PartnerController.name, () => {
const { status, body } = await request(ctx.getHttpServer()).get(`/partners`).set('Authorization', `Bearer token`); const { status, body } = await request(ctx.getHttpServer()).get(`/partners`).set('Authorization', `Bearer token`);
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(
errorDto.validationError([ errorDto.badRequest([expect.stringContaining('[direction] Invalid option: expected one of')]),
{ path: ['direction'], message: expect.stringContaining('Invalid option: expected one of') },
]),
); );
}); });
@@ -46,9 +44,7 @@ describe(PartnerController.name, () => {
.set('Authorization', `Bearer token`); .set('Authorization', `Bearer token`);
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(
errorDto.validationError([ errorDto.badRequest([expect.stringContaining('[direction] Invalid option: expected one of')]),
{ path: ['direction'], message: expect.stringContaining('Invalid option: expected one of') },
]),
); );
}); });
}); });
@@ -65,7 +61,7 @@ describe(PartnerController.name, () => {
.send({ sharedWithId: 'invalid' }) .send({ sharedWithId: 'invalid' })
.set('Authorization', `Bearer token`); .set('Authorization', `Bearer token`);
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(errorDto.validationError([{ path: ['sharedWithId'], message: 'Invalid UUID' }])); expect(body).toEqual(errorDto.badRequest(['[sharedWithId] Invalid UUID']));
}); });
}); });
@@ -81,7 +77,7 @@ describe(PartnerController.name, () => {
.send({ inTimeline: true }) .send({ inTimeline: true })
.set('Authorization', `Bearer token`); .set('Authorization', `Bearer token`);
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }])); expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID']));
}); });
}); });
@@ -96,7 +92,7 @@ describe(PartnerController.name, () => {
.delete(`/partners/invalid`) .delete(`/partners/invalid`)
.set('Authorization', `Bearer token`); .set('Authorization', `Bearer token`);
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }])); expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID']));
}); });
}); });
}); });
@@ -35,7 +35,7 @@ describe(PersonController.name, () => {
.query({ closestPersonId: 'invalid' }) .query({ closestPersonId: 'invalid' })
.set('Authorization', `Bearer token`); .set('Authorization', `Bearer token`);
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(errorDto.validationError([{ path: ['closestPersonId'], message: 'Invalid UUID' }])); expect(body).toEqual(errorDto.badRequest(['[closestPersonId] Invalid UUID']));
}); });
it(`should require closestAssetId to be a uuid`, async () => { it(`should require closestAssetId to be a uuid`, async () => {
@@ -44,7 +44,7 @@ describe(PersonController.name, () => {
.query({ closestAssetId: 'invalid' }) .query({ closestAssetId: 'invalid' })
.set('Authorization', `Bearer token`); .set('Authorization', `Bearer token`);
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(errorDto.validationError([{ path: ['closestAssetId'], message: 'Invalid UUID' }])); expect(body).toEqual(errorDto.badRequest(['[closestAssetId] Invalid UUID']));
}); });
}); });
@@ -76,7 +76,7 @@ describe(PersonController.name, () => {
.delete('/people') .delete('/people')
.send({ ids: ['invalid'] }); .send({ ids: ['invalid'] });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(errorDto.validationError([{ path: ['ids', 0], message: 'Invalid UUID' }])); expect(body).toEqual(errorDto.badRequest(['[ids.0] Invalid UUID']));
}); });
it('should respond with 204', async () => { it('should respond with 204', async () => {
@@ -104,9 +104,7 @@ describe(PersonController.name, () => {
it('should require a valid uuid', async () => { it('should require a valid uuid', async () => {
const { status, body } = await request(ctx.getHttpServer()).put(`/people/123`); const { status, body } = await request(ctx.getHttpServer()).put(`/people/123`);
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(errorDto.badRequest(['Invalid input: expected object, received undefined']));
errorDto.validationError([{ path: [], message: 'Invalid input: expected object, received undefined' }]),
);
}); });
it(`should not allow a null name`, async () => { it(`should not allow a null name`, async () => {
@@ -115,9 +113,7 @@ describe(PersonController.name, () => {
.send({ name: null }) .send({ name: null })
.set('Authorization', `Bearer token`); .set('Authorization', `Bearer token`);
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(errorDto.badRequest(['[name] Invalid input: expected string, received null']));
errorDto.validationError([{ path: ['name'], message: 'Invalid input: expected string, received null' }]),
);
}); });
it(`should require featureFaceAssetId to be a uuid`, async () => { it(`should require featureFaceAssetId to be a uuid`, async () => {
@@ -126,7 +122,7 @@ describe(PersonController.name, () => {
.send({ featureFaceAssetId: 'invalid' }) .send({ featureFaceAssetId: 'invalid' })
.set('Authorization', `Bearer token`); .set('Authorization', `Bearer token`);
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(errorDto.validationError([{ path: ['featureFaceAssetId'], message: 'Invalid UUID' }])); expect(body).toEqual(errorDto.badRequest(['[featureFaceAssetId] Invalid UUID']));
}); });
it(`should require isFavorite to be a boolean`, async () => { it(`should require isFavorite to be a boolean`, async () => {
@@ -135,11 +131,7 @@ describe(PersonController.name, () => {
.send({ isFavorite: 'invalid' }) .send({ isFavorite: 'invalid' })
.set('Authorization', `Bearer token`); .set('Authorization', `Bearer token`);
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(errorDto.badRequest(['[isFavorite] Invalid input: expected boolean, received string']));
errorDto.validationError([
{ path: ['isFavorite'], message: 'Invalid input: expected boolean, received string' },
]),
);
}); });
it(`should require isHidden to be a boolean`, async () => { it(`should require isHidden to be a boolean`, async () => {
@@ -148,9 +140,7 @@ describe(PersonController.name, () => {
.send({ isHidden: 'invalid' }) .send({ isHidden: 'invalid' })
.set('Authorization', `Bearer token`); .set('Authorization', `Bearer token`);
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(errorDto.badRequest(['[isHidden] Invalid input: expected boolean, received string']));
errorDto.validationError([{ path: ['isHidden'], message: 'Invalid input: expected boolean, received string' }]),
);
}); });
it('should map an empty birthDate to null', async () => { it('should map an empty birthDate to null', async () => {
@@ -164,11 +154,7 @@ describe(PersonController.name, () => {
.put(`/people/${factory.uuid()}`) .put(`/people/${factory.uuid()}`)
.send({ birthDate: false }); .send({ birthDate: false });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(errorDto.badRequest(['[birthDate] Invalid input: expected string, received boolean']));
errorDto.validationError([
{ path: ['birthDate'], message: 'Invalid input: expected string, received boolean' },
]),
);
}); });
it('should not accept an invalid birth date (number)', async () => { it('should not accept an invalid birth date (number)', async () => {
@@ -176,9 +162,7 @@ describe(PersonController.name, () => {
.put(`/people/${factory.uuid()}`) .put(`/people/${factory.uuid()}`)
.send({ birthDate: 123_456 }); .send({ birthDate: 123_456 });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(errorDto.badRequest(['[birthDate] Invalid input: expected string, received number']));
errorDto.validationError([{ path: ['birthDate'], message: 'Invalid input: expected string, received number' }]),
);
}); });
it('should not accept a birth date in the future)', async () => { it('should not accept a birth date in the future)', async () => {
@@ -186,9 +170,7 @@ describe(PersonController.name, () => {
.put(`/people/${factory.uuid()}`) .put(`/people/${factory.uuid()}`)
.send({ birthDate: '9999-01-01' }); .send({ birthDate: '9999-01-01' });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(errorDto.badRequest(['[birthDate] Birth date cannot be in the future']));
errorDto.validationError([{ path: ['birthDate'], message: 'Birth date cannot be in the future' }]),
);
}); });
}); });
@@ -201,7 +183,7 @@ describe(PersonController.name, () => {
it('should require a valid uuid', async () => { it('should require a valid uuid', async () => {
const { status, body } = await request(ctx.getHttpServer()).delete(`/people/invalid`); const { status, body } = await request(ctx.getHttpServer()).delete(`/people/invalid`);
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }])); expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID']));
}); });
it('should respond with 204', async () => { it('should respond with 204', async () => {
@@ -27,41 +27,31 @@ describe(SearchController.name, () => {
it('should reject page as a string', async () => { it('should reject page as a string', async () => {
const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ page: 'abc' }); const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ page: 'abc' });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(errorDto.badRequest(['[page] Invalid input: expected number, received string']));
errorDto.validationError([{ path: ['page'], message: 'Invalid input: expected number, received string' }]),
);
}); });
it('should reject page as a negative number', async () => { it('should reject page as a negative number', async () => {
const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ page: -10 }); const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ page: -10 });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(errorDto.badRequest(['[page] Too small: expected number to be >=1']));
errorDto.validationError([{ path: ['page'], message: 'Too small: expected number to be >=1' }]),
);
}); });
it('should reject page as 0', async () => { it('should reject page as 0', async () => {
const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ page: 0 }); const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ page: 0 });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(errorDto.badRequest(['[page] Too small: expected number to be >=1']));
errorDto.validationError([{ path: ['page'], message: 'Too small: expected number to be >=1' }]),
);
}); });
it('should reject size as a string', async () => { it('should reject size as a string', async () => {
const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ size: 'abc' }); const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ size: 'abc' });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(errorDto.badRequest(['[size] Invalid input: expected number, received string']));
errorDto.validationError([{ path: ['size'], message: 'Invalid input: expected number, received string' }]),
);
}); });
it('should reject an invalid size', async () => { it('should reject an invalid size', async () => {
const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ size: -1 }); const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ size: -1 });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(errorDto.badRequest(['[size] Too small: expected number to be >=1']));
errorDto.validationError([{ path: ['size'], message: 'Too small: expected number to be >=1' }]),
);
}); });
it('should reject an visibility as not an enum', async () => { it('should reject an visibility as not an enum', async () => {
@@ -70,9 +60,7 @@ describe(SearchController.name, () => {
.send({ visibility: 'immich' }); .send({ visibility: 'immich' });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(
errorDto.validationError([ errorDto.badRequest([expect.stringContaining('[visibility] Invalid option: expected one of')]),
{ path: ['visibility'], message: expect.stringContaining('Invalid option: expected one of') },
]),
); );
}); });
@@ -81,11 +69,7 @@ describe(SearchController.name, () => {
.post('/search/metadata') .post('/search/metadata')
.send({ isFavorite: 'immich' }); .send({ isFavorite: 'immich' });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(errorDto.badRequest(['[isFavorite] Invalid input: expected boolean, received string']));
errorDto.validationError([
{ path: ['isFavorite'], message: 'Invalid input: expected boolean, received string' },
]),
);
}); });
it('should reject an isEncoded as not a boolean', async () => { it('should reject an isEncoded as not a boolean', async () => {
@@ -93,11 +77,7 @@ describe(SearchController.name, () => {
.post('/search/metadata') .post('/search/metadata')
.send({ isEncoded: 'immich' }); .send({ isEncoded: 'immich' });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(errorDto.badRequest(['[isEncoded] Invalid input: expected boolean, received string']));
errorDto.validationError([
{ path: ['isEncoded'], message: 'Invalid input: expected boolean, received string' },
]),
);
}); });
it('should reject an isOffline as not a boolean', async () => { it('should reject an isOffline as not a boolean', async () => {
@@ -105,19 +85,13 @@ describe(SearchController.name, () => {
.post('/search/metadata') .post('/search/metadata')
.send({ isOffline: 'immich' }); .send({ isOffline: 'immich' });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(errorDto.badRequest(['[isOffline] Invalid input: expected boolean, received string']));
errorDto.validationError([
{ path: ['isOffline'], message: 'Invalid input: expected boolean, received string' },
]),
);
}); });
it('should reject an isMotion as not a boolean', async () => { it('should reject an isMotion as not a boolean', async () => {
const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ isMotion: 'immich' }); const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ isMotion: 'immich' });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(errorDto.badRequest(['[isMotion] Invalid input: expected boolean, received string']));
errorDto.validationError([{ path: ['isMotion'], message: 'Invalid input: expected boolean, received string' }]),
);
}); });
describe('POST /search/random', () => { describe('POST /search/random', () => {
@@ -131,11 +105,7 @@ describe(SearchController.name, () => {
.post('/search/random') .post('/search/random')
.send({ withStacked: 'immich' }); .send({ withStacked: 'immich' });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(errorDto.badRequest(['[withStacked] Invalid input: expected boolean, received string']));
errorDto.validationError([
{ path: ['withStacked'], message: 'Invalid input: expected boolean, received string' },
]),
);
}); });
it('should reject if withPeople is not a boolean', async () => { it('should reject if withPeople is not a boolean', async () => {
@@ -143,11 +113,7 @@ describe(SearchController.name, () => {
.post('/search/random') .post('/search/random')
.send({ withPeople: 'immich' }); .send({ withPeople: 'immich' });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(errorDto.badRequest(['[withPeople] Invalid input: expected boolean, received string']));
errorDto.validationError([
{ path: ['withPeople'], message: 'Invalid input: expected boolean, received string' },
]),
);
}); });
}); });
@@ -174,9 +140,7 @@ describe(SearchController.name, () => {
it('should require a name', async () => { it('should require a name', async () => {
const { status, body } = await request(ctx.getHttpServer()).get('/search/person').send({}); const { status, body } = await request(ctx.getHttpServer()).get('/search/person').send({});
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(errorDto.badRequest(['[name] Invalid input: expected string, received undefined']));
errorDto.validationError([{ path: ['name'], message: 'Invalid input: expected string, received undefined' }]),
);
}); });
}); });
@@ -189,9 +153,7 @@ describe(SearchController.name, () => {
it('should require a name', async () => { it('should require a name', async () => {
const { status, body } = await request(ctx.getHttpServer()).get('/search/places').send({}); const { status, body } = await request(ctx.getHttpServer()).get('/search/places').send({});
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(errorDto.badRequest(['[name] Invalid input: expected string, received undefined']));
errorDto.validationError([{ path: ['name'], message: 'Invalid input: expected string, received undefined' }]),
);
}); });
}); });
@@ -211,11 +173,7 @@ describe(SearchController.name, () => {
it('should require a type', async () => { it('should require a type', async () => {
const { status, body } = await request(ctx.getHttpServer()).get('/search/suggestions').send({}); const { status, body } = await request(ctx.getHttpServer()).get('/search/suggestions').send({});
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(errorDto.badRequest([expect.stringContaining('[type] Invalid option: expected one of')]));
errorDto.validationError([
{ path: ['type'], message: expect.stringContaining('Invalid option: expected one of') },
]),
);
}); });
}); });
}); });
+3 -13
View File
@@ -35,11 +35,7 @@ describe(SyncController.name, () => {
.post('/sync/stream') .post('/sync/stream')
.send({ types: ['invalid'] }); .send({ types: ['invalid'] });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(errorDto.badRequest([expect.stringContaining('[types.0] Invalid option: expected one of')]));
errorDto.validationError([
{ path: ['types', 0], message: expect.stringContaining('Invalid option: expected one of') },
]),
);
expect(ctx.authenticate).toHaveBeenCalled(); expect(ctx.authenticate).toHaveBeenCalled();
}); });
}); });
@@ -61,9 +57,7 @@ describe(SyncController.name, () => {
const acks = Array.from({ length: 1001 }, (_, i) => `ack-${i}`); const acks = Array.from({ length: 1001 }, (_, i) => `ack-${i}`);
const { status, body } = await request(ctx.getHttpServer()).post('/sync/ack').send({ acks }); const { status, body } = await request(ctx.getHttpServer()).post('/sync/ack').send({ acks });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(errorDto.badRequest(['[acks] Too big: expected array to have <=1000 items']));
errorDto.validationError([{ path: ['acks'], message: 'Too big: expected array to have <=1000 items' }]),
);
expect(ctx.authenticate).toHaveBeenCalled(); expect(ctx.authenticate).toHaveBeenCalled();
}); });
}); });
@@ -79,11 +73,7 @@ describe(SyncController.name, () => {
.delete('/sync/ack') .delete('/sync/ack')
.send({ types: ['invalid'] }); .send({ types: ['invalid'] });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(errorDto.badRequest([expect.stringContaining('[types.0] Invalid option: expected one of')]));
errorDto.validationError([
{ path: ['types', 0], message: expect.stringContaining('Invalid option: expected one of') },
]),
);
expect(ctx.authenticate).toHaveBeenCalled(); expect(ctx.authenticate).toHaveBeenCalled();
}); });
}); });
@@ -67,11 +67,8 @@ describe(SystemConfigController.name, () => {
const { status, body } = await request(ctx.getHttpServer()).put('/system-config').send(config); const { status, body } = await request(ctx.getHttpServer()).put('/system-config').send(config);
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(
errorDto.validationError([ errorDto.badRequest([
{ '[nightlyTasks.startTime] Invalid input: expected string in HH:mm format, received string',
path: ['nightlyTasks', 'startTime'],
message: 'Invalid input: expected string in HH:mm format, received string',
},
]), ]),
); );
}); });
@@ -89,9 +86,7 @@ describe(SystemConfigController.name, () => {
const { status, body } = await request(ctx.getHttpServer()).put('/system-config').send(config); const { status, body } = await request(ctx.getHttpServer()).put('/system-config').send(config);
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(
errorDto.validationError([ errorDto.badRequest(['[nightlyTasks.databaseCleanup] Invalid input: expected boolean, received string']),
{ path: ['nightlyTasks', 'databaseCleanup'], message: 'Invalid input: expected boolean, received string' },
]),
); );
}); });
}); });
@@ -121,12 +116,7 @@ describe(SystemConfigController.name, () => {
const { status, body } = await request(ctx.getHttpServer()).put('/system-config').send(config); const { status, body } = await request(ctx.getHttpServer()).put('/system-config').send(config);
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(
errorDto.validationError([ errorDto.badRequest(['[image.thumbnail.progressive] Invalid input: expected boolean, received string']),
{
path: ['image', 'thumbnail', 'progressive'],
message: 'Invalid input: expected boolean, received string',
},
]),
); );
}); });
}); });
@@ -54,7 +54,7 @@ describe(TagController.name, () => {
it('should require a valid uuid', async () => { it('should require a valid uuid', async () => {
const { status, body } = await request(ctx.getHttpServer()).get(`/tags/123`); const { status, body } = await request(ctx.getHttpServer()).get(`/tags/123`);
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }])); expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID']));
}); });
}); });
@@ -42,9 +42,7 @@ describe(TimelineController.name, () => {
const { status, body } = await request(ctx.getHttpServer()).get('/timeline/buckets').query({ bbox: '1,2,3' }); const { status, body } = await request(ctx.getHttpServer()).get('/timeline/buckets').query({ bbox: '1,2,3' });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(
errorDto.validationError([ errorDto.badRequest(['[bbox] bbox must have 4 comma-separated numbers: west,south,east,north'] as any),
{ path: ['bbox'], message: 'bbox must have 4 comma-separated numbers: west,south,east,north' },
]),
); );
}); });
@@ -53,7 +51,7 @@ describe(TimelineController.name, () => {
.get('/timeline/buckets') .get('/timeline/buckets')
.query({ bbox: '1,2,3,invalid' }); .query({ bbox: '1,2,3,invalid' });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(errorDto.validationError([{ path: ['bbox'], message: 'bbox parts must be valid numbers' }])); expect(body).toEqual(errorDto.badRequest(['[bbox] bbox parts must be valid numbers'] as any));
}); });
}); });
@@ -78,9 +78,9 @@ describe(UserAdminController.name, () => {
.send(dto); .send(dto);
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(
errorDto.validationError([ errorDto.badRequest(
{ path: ['quotaSizeInBytes'], message: 'Invalid input: expected int, received number' }, expect.arrayContaining(['[quotaSizeInBytes] Invalid input: expected int, received number']),
]), ),
); );
}); });
@@ -98,9 +98,9 @@ describe(UserAdminController.name, () => {
.send(dto); .send(dto);
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(
errorDto.validationError([ errorDto.badRequest(
{ path: ['quotaSizeInBytes'], message: 'Invalid input: expected int, received number' }, expect.arrayContaining(['[quotaSizeInBytes] Invalid input: expected int, received number']),
]), ),
); );
}); });
}); });
@@ -125,9 +125,9 @@ describe(UserAdminController.name, () => {
.send({ quotaSizeInBytes: 1.2 }); .send({ quotaSizeInBytes: 1.2 });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(
errorDto.validationError([ errorDto.badRequest(
{ path: ['quotaSizeInBytes'], message: 'Invalid input: expected int, received number' }, expect.arrayContaining(['[quotaSizeInBytes] Invalid input: expected int, received number']),
]), ),
); );
}); });
@@ -43,17 +43,15 @@ describe(UserController.name, () => {
expect(ctx.authenticate).toHaveBeenCalled(); expect(ctx.authenticate).toHaveBeenCalled();
}); });
for (const [key, message] of [ for (const key of ['email', 'name']) {
['email', 'Invalid input: expected email, received object'],
['name', 'Invalid input: expected string, received null'],
] as const) {
it(`should not allow null ${key}`, async () => { it(`should not allow null ${key}`, async () => {
const dto = { [key]: null };
const { status, body } = await request(ctx.getHttpServer()) const { status, body } = await request(ctx.getHttpServer())
.put(`/users/me`) .put(`/users/me`)
.set('Authorization', `Bearer token`) .set('Authorization', `Bearer token`)
.send({ [key]: null }); .send(dto);
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(errorDto.validationError([{ path: [key], message }])); expect(body).toEqual(errorDto.badRequest());
}); });
} }
+2
View File
@@ -3,6 +3,7 @@ import { MapAsset } from 'src/dtos/asset-response.dto';
import { import {
AlbumUserRole, AlbumUserRole,
AssetFileType, AssetFileType,
AssetOrder,
AssetType, AssetType,
AssetVisibility, AssetVisibility,
ChecksumAlgorithm, ChecksumAlgorithm,
@@ -195,6 +196,7 @@ export type SharedLink = {
}; };
export type Album = Selectable<AlbumTable> & { export type Album = Selectable<AlbumTable> & {
order: AssetOrder;
assets: ShallowDehydrateObject<Selectable<AssetTable>>[]; assets: ShallowDehydrateObject<Selectable<AssetTable>>[];
}; };
@@ -40,16 +40,16 @@ export class GlobalExceptionFilter implements ExceptionFilter<Error> {
if (error instanceof ZodValidationException || error instanceof ZodSerializationException) { if (error instanceof ZodValidationException || error instanceof ZodSerializationException) {
const zodError = error.getZodError(); const zodError = error.getZodError();
if (zodError instanceof ZodError && zodError.issues.length > 0) { if (zodError instanceof ZodError && zodError.issues.length > 0) {
return { body['message'] = zodError.issues.map((issue) =>
status, issue.path.length > 0 ? `[${issue.path.join('.')}] ${issue.message}` : issue.message,
body: { message: 'Validation failed', errors: zodError.issues }, );
};
} }
} }
// remove fields injected by NestJS that duplicate the HTTP response line // remove fields that duplicate the HTTP response line or will be reformatted in a later step
delete body['error']; delete body['error'];
delete body['statusCode']; delete body['statusCode'];
delete body['errors'];
return { status, body }; return { status, body };
} }
+52 -13
View File
@@ -14,7 +14,7 @@ import { InjectKysely } from 'nestjs-kysely';
import { columns } from 'src/database'; import { columns } from 'src/database';
import { Chunked, ChunkedArray, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators'; import { Chunked, ChunkedArray, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators';
import { AlbumUserCreateDto } from 'src/dtos/album.dto'; import { AlbumUserCreateDto } from 'src/dtos/album.dto';
import { AlbumUserRole } from 'src/enum'; import { AlbumUserRole, AssetOrder } from 'src/enum';
import { DB } from 'src/schema'; import { DB } from 'src/schema';
import { AlbumTable } from 'src/schema/tables/album.table'; import { AlbumTable } from 'src/schema/tables/album.table';
import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
@@ -82,6 +82,23 @@ const isAlbumOwned = (ownerId: string) => (eb: ExpressionBuilder<DB, 'album'>) =
.where('album_user.userId', '=', ownerId), .where('album_user.userId', '=', ownerId),
); );
const withOrder = (authUserId?: string) => (eb: ExpressionBuilder<DB, 'album'>) => {
const defaultOrder = sql<AssetOrder>`${AssetOrder.Desc}`;
return authUserId
? eb.fn
.coalesce(
eb
.selectFrom('album_user')
.select('album_user.order')
.whereRef('album_user.albumId', '=', 'album.id')
.where('album_user.userId', '=', authUserId),
defaultOrder,
)
.as('order')
: defaultOrder.as('order');
};
@Injectable() @Injectable()
export class AlbumRepository { export class AlbumRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {} constructor(@InjectKysely() private db: Kysely<DB>) {}
@@ -95,6 +112,7 @@ export class AlbumRepository {
.where('album.id', '=', id) .where('album.id', '=', id)
.where('album.deletedAt', 'is', null) .where('album.deletedAt', 'is', null)
.select(withAlbumUsers(authUserId)) .select(withAlbumUsers(authUserId))
.select(withOrder(authUserId))
.select(withSharedLink) .select(withSharedLink)
.$if(options.withAssets, (eb) => eb.select(withAssets)) .$if(options.withAssets, (eb) => eb.select(withAssets))
.$narrowType<{ assets: NotNull }>() .$narrowType<{ assets: NotNull }>()
@@ -118,6 +136,7 @@ export class AlbumRepository {
.where('album_asset.assetId', '=', assetId) .where('album_asset.assetId', '=', assetId)
.where('album.deletedAt', 'is', null) .where('album.deletedAt', 'is', null)
.select(withAlbumUsers(ownerId)) .select(withAlbumUsers(ownerId))
.select(withOrder(ownerId))
.orderBy('album.createdAt', 'desc') .orderBy('album.createdAt', 'desc')
.execute(); .execute();
} }
@@ -195,6 +214,7 @@ export class AlbumRepository {
.on('album_user.role', '=', sql.lit(AlbumUserRole.Owner)), .on('album_user.role', '=', sql.lit(AlbumUserRole.Owner)),
) )
.where('album.deletedAt', 'is', null) .where('album.deletedAt', 'is', null)
.select('album_user.order as order')
.select(withAlbumUsers(ownerId)) .select(withAlbumUsers(ownerId))
.select(withSharedLink) .select(withSharedLink)
.orderBy('album.createdAt', 'desc') .orderBy('album.createdAt', 'desc')
@@ -239,6 +259,7 @@ export class AlbumRepository {
) )
.where('album.deletedAt', 'is', null) .where('album.deletedAt', 'is', null)
.select(withAlbumUsers(ownerId)) .select(withAlbumUsers(ownerId))
.select(withOrder(ownerId))
.select(withSharedLink) .select(withSharedLink)
.orderBy('album.createdAt', 'desc') .orderBy('album.createdAt', 'desc')
.execute(); .execute();
@@ -271,6 +292,7 @@ export class AlbumRepository {
.where(({ not, exists, selectFrom }) => .where(({ not, exists, selectFrom }) =>
not(exists(selectFrom('shared_link').whereRef('shared_link.albumId', '=', 'album.id'))), not(exists(selectFrom('shared_link').whereRef('shared_link.albumId', '=', 'album.id'))),
) )
.select('album_user.order as order')
.select(withSharedLink) .select(withSharedLink)
.select(withAlbumUsers(ownerId)) .select(withAlbumUsers(ownerId))
.orderBy('album.createdAt', 'desc') .orderBy('album.createdAt', 'desc')
@@ -350,13 +372,13 @@ export class AlbumRepository {
params: [ params: [
{ albumName: DummyValue.STRING }, { albumName: DummyValue.STRING },
[], [],
[{ userId: DummyValue.UUID, role: AlbumUserRole.Owner }, DummyValue.UUID], [{ userId: DummyValue.UUID, role: AlbumUserRole.Owner, order: AssetOrder.Desc }, DummyValue.UUID],
], ],
}) })
async create( async create(
album: Insertable<AlbumTable>, album: Insertable<AlbumTable>,
assetIds: string[], assetIds: string[],
albumUsers: AlbumUserCreateDto[], albumUsers: (AlbumUserCreateDto & { order?: AssetOrder })[],
authUserId: string, authUserId: string,
) { ) {
if (!albumUsers.some((u) => u.role === AlbumUserRole.Owner)) { if (!albumUsers.some((u) => u.role === AlbumUserRole.Owner)) {
@@ -365,12 +387,14 @@ export class AlbumRepository {
const userIds = albumUsers.map((u) => u.userId); const userIds = albumUsers.map((u) => u.userId);
const roles = albumUsers.map((u) => u.role); const roles = albumUsers.map((u) => u.role);
const orders = albumUsers.map((u) => u.order ?? AssetOrder.Desc);
const result = await this.db const result = await this.db
.with('album', (db) => db.insertInto('album').values(album).returningAll()) .with('album', (db) => db.insertInto('album').values(album).returningAll())
.with('album_user', (db) => .with('album_user', (db) =>
db db
.insertInto('album_user') .insertInto('album_user')
.columns(['albumId', 'userId', 'role', 'order'])
.expression((eb) => .expression((eb) =>
eb eb
.selectFrom('album') .selectFrom('album')
@@ -378,13 +402,15 @@ export class AlbumRepository {
ref('album.id').as('albumId'), ref('album.id').as('albumId'),
sql`unnest(${userIds}::uuid[])`.as('userId'), sql`unnest(${userIds}::uuid[])`.as('userId'),
sql`unnest(${roles}::album_user_role_enum[])`.as('role'), sql`unnest(${roles}::album_user_role_enum[])`.as('role'),
sql`unnest(${orders}::varchar[])`.as('order'),
]), ]),
) )
.returning(['album_user.albumId', 'album_user.userId', 'album_user.role']), .returning(['album_user.albumId', 'album_user.userId', 'album_user.role', 'album_user.order']),
) )
.with('album_asset', (db) => .with('album_asset', (db) =>
db db
.insertInto('album_asset') .insertInto('album_asset')
.columns(['albumId', 'assetId'])
.expression((eb) => .expression((eb) =>
eb eb
.selectFrom('album') .selectFrom('album')
@@ -396,6 +422,7 @@ export class AlbumRepository {
.selectFrom('album') .selectFrom('album')
.selectAll('album') .selectAll('album')
.select(withAlbumUsers(authUserId)) .select(withAlbumUsers(authUserId))
.select(withOrder(authUserId))
.select(withAssets) .select(withAssets)
.$narrowType<{ assets: NotNull }>() .$narrowType<{ assets: NotNull }>()
.executeTakeFirstOrThrow(); .executeTakeFirstOrThrow();
@@ -403,15 +430,27 @@ export class AlbumRepository {
return result; return result;
} }
update(id: string, album: Updateable<AlbumTable>, authUserId: string) { update(id: string, album: Updateable<AlbumTable>, authUserId: string, order?: AssetOrder) {
return this.db return this.db.transaction().execute(async (db) => {
.updateTable('album') if (order !== undefined) {
.set(album) await db
.where('album.id', '=', id) .updateTable('album_user')
.returningAll('album') .set({ order })
.returning(withSharedLink) .where('albumId', '=', id)
.returning(withAlbumUsers(authUserId)) .where('userId', '=', authUserId)
.executeTakeFirstOrThrow(); .execute();
}
return db
.updateTable('album')
.set(album)
.where('album.id', '=', id)
.returningAll('album')
.returning(withSharedLink)
.returning(withAlbumUsers(authUserId))
.returning(withOrder(authUserId))
.executeTakeFirstOrThrow();
});
} }
async delete(id: string): Promise<void> { async delete(id: string): Promise<void> {
@@ -5,7 +5,7 @@ import _ from 'lodash';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { Album, columns } from 'src/database'; import { Album, columns } from 'src/database';
import { ChunkedArray, DummyValue, GenerateSql } from 'src/decorators'; import { ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
import { AlbumUserRole, SharedLinkType } from 'src/enum'; import { AlbumUserRole, AssetOrder, SharedLinkType } from 'src/enum';
import { DB } from 'src/schema'; import { DB } from 'src/schema';
import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
import { AssetTable } from 'src/schema/tables/asset.table'; import { AssetTable } from 'src/schema/tables/asset.table';
@@ -55,7 +55,15 @@ const withAlbumOwner = (eb: ExpressionBuilder<DB, 'album'>) => {
const withSharedLinkAlbum = (eb: ExpressionBuilder<DB, 'shared_link'>) => { const withSharedLinkAlbum = (eb: ExpressionBuilder<DB, 'shared_link'>) => {
return eb return eb
.selectFrom('album') .selectFrom('album')
.leftJoin('album_user as album_order', (join) =>
join
.onRef('album_order.albumId', '=', 'album.id')
.onRef('album_order.userId', '=', 'shared_link.userId'),
)
.selectAll('album') .selectAll('album')
.select((eb) =>
eb.fn.coalesce('album_order.order', sql<AssetOrder>`${AssetOrder.Desc}`).as('order'),
)
.whereRef('album.id', '=', 'shared_link.albumId') .whereRef('album.id', '=', 'shared_link.albumId')
.where('album.deletedAt', 'is', null); .where('album.deletedAt', 'is', null);
}; };
@@ -107,7 +115,7 @@ export class SharedLinkRepository {
.as('assets'), .as('assets'),
) )
.select((eb) => eb.fn.toJson('owner').as('owner')) .select((eb) => eb.fn.toJson('owner').as('owner'))
.groupBy(['album.id', sql`"owner".*`]) .groupBy(['album.id', 'album_order.order', sql`"owner".*`])
.as('album'), .as('album'),
(join) => join.onTrue(), (join) => join.onTrue(),
) )
+2 -2
View File
@@ -170,7 +170,7 @@ class AlbumSync extends BaseSync {
const userId = options.userId; const userId = options.userId;
return this.upsertQuery('album', options) return this.upsertQuery('album', options)
.distinctOn(['album.id', 'album.updateId']) .distinctOn(['album.id', 'album.updateId'])
.leftJoin('album_user as album_users', 'album.id', 'album_users.albumId') .innerJoin('album_user as album_users', 'album.id', 'album_users.albumId')
.where('album_users.userId', '=', userId) .where('album_users.userId', '=', userId)
.select([ .select([
'album.id', 'album.id',
@@ -180,7 +180,7 @@ class AlbumSync extends BaseSync {
'album.updatedAt', 'album.updatedAt',
'album.albumThumbnailAssetId as thumbnailAssetId', 'album.albumThumbnailAssetId as thumbnailAssetId',
'album.isActivityEnabled', 'album.isActivityEnabled',
'album.order', 'album_users.order as order',
'album.updateId', 'album.updateId',
]) ])
.stream(); .stream();
@@ -0,0 +1,21 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "album_user" ADD "order" character varying NOT NULL DEFAULT 'desc';`.execute(db);
await sql`UPDATE "album_user" SET "order" = "album"."order" FROM "album" WHERE "album_user"."albumId" = "album"."id";`.execute(
db,
);
await sql`ALTER TABLE "album" DROP COLUMN "order";`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "album" ADD "order" character varying NOT NULL DEFAULT 'desc';`.execute(db);
await sql`
UPDATE "album"
SET "order" = "album_user"."order"
FROM "album_user"
WHERE "album_user"."albumId" = "album"."id"
AND "album_user"."role" = 'owner';
`.execute(db);
await sql`ALTER TABLE "album_user" DROP COLUMN "order";`.execute(db);
}
+4 -1
View File
@@ -11,7 +11,7 @@ import {
UpdateDateColumn, UpdateDateColumn,
} from '@immich/sql-tools'; } from '@immich/sql-tools';
import { CreateIdColumn, UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; import { CreateIdColumn, UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { AlbumUserRole } from 'src/enum'; import { AlbumUserRole, AssetOrder } from 'src/enum';
import { album_user_role_enum } from 'src/schema/enums'; import { album_user_role_enum } from 'src/schema/enums';
import { album_user_after_insert, album_user_delete_audit } from 'src/schema/functions'; import { album_user_after_insert, album_user_delete_audit } from 'src/schema/functions';
import { AlbumTable } from 'src/schema/tables/album.table'; import { AlbumTable } from 'src/schema/tables/album.table';
@@ -58,6 +58,9 @@ export class AlbumUserTable {
@Column({ enum: album_user_role_enum, default: AlbumUserRole.Editor }) @Column({ enum: album_user_role_enum, default: AlbumUserRole.Editor })
role!: Generated<AlbumUserRole>; role!: Generated<AlbumUserRole>;
@Column({ default: AssetOrder.Desc })
order!: Generated<AssetOrder>;
@CreateIdColumn({ index: true }) @CreateIdColumn({ index: true })
createId!: Generated<string>; createId!: Generated<string>;
-4
View File
@@ -10,7 +10,6 @@ import {
UpdateDateColumn, UpdateDateColumn,
} from '@immich/sql-tools'; } from '@immich/sql-tools';
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { AssetOrder } from 'src/enum';
import { AssetTable } from 'src/schema/tables/asset.table'; import { AssetTable } from 'src/schema/tables/asset.table';
@Table({ name: 'album' }) @Table({ name: 'album' })
@@ -45,9 +44,6 @@ export class AlbumTable {
@Column({ type: 'boolean', default: true }) @Column({ type: 'boolean', default: true })
isActivityEnabled!: Generated<boolean>; isActivityEnabled!: Generated<boolean>;
@Column({ default: AssetOrder.Desc })
order!: Generated<AssetOrder>;
@UpdateIdColumn({ index: true }) @UpdateIdColumn({ index: true })
updateId!: Generated<string>; updateId!: Generated<string>;
} }
+27 -8
View File
@@ -182,19 +182,19 @@ describe(AlbumService.name, () => {
{ {
albumName: 'test', albumName: 'test',
description: 'description', description: 'description',
order: album.order,
albumThumbnailAssetId: assetId, albumThumbnailAssetId: assetId,
}, },
[assetId], [assetId],
[ [
{ userId: owner.id, role: AlbumUserRole.Owner }, { userId: owner.id, role: AlbumUserRole.Owner, order: AssetOrder.Desc },
{ userId: albumUser.userId, role: AlbumUserRole.Editor }, { userId: albumUser.userId, role: AlbumUserRole.Editor, order: AssetOrder.Desc },
], ],
owner.id, owner.id,
); );
expect(mocks.user.get).toHaveBeenCalledWith(albumUser.userId, {}); expect(mocks.user.get).toHaveBeenCalledWith(albumUser.userId, {});
expect(mocks.user.getMetadata).toHaveBeenCalledWith(owner.id); expect(mocks.user.getMetadata).toHaveBeenCalledWith(owner.id);
expect(mocks.user.getMetadata).toHaveBeenCalledWith(albumUser.userId);
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(owner.id, new Set([assetId]), false); expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(owner.id, new Set([assetId]), false);
expect(mocks.event.emit).toHaveBeenCalledTimes(1); expect(mocks.event.emit).toHaveBeenCalledTimes(1);
expect(mocks.event.emit).toHaveBeenCalledWith('AlbumInvite', { expect(mocks.event.emit).toHaveBeenCalledWith('AlbumInvite', {
@@ -238,16 +238,19 @@ describe(AlbumService.name, () => {
{ {
albumName: album.albumName, albumName: album.albumName,
description: album.description, description: album.description,
order: 'asc',
albumThumbnailAssetId: assetId, albumThumbnailAssetId: assetId,
}, },
[assetId], [assetId],
[{ userId: owner.id, role: AlbumUserRole.Owner }, albumUser], [
{ userId: owner.id, role: AlbumUserRole.Owner, order: 'asc' },
{ ...albumUser, order: 'asc' },
],
owner.id, owner.id,
); );
expect(mocks.user.get).toHaveBeenCalledWith(albumUser.userId, {}); expect(mocks.user.get).toHaveBeenCalledWith(albumUser.userId, {});
expect(mocks.user.getMetadata).toHaveBeenCalledWith(owner.id); expect(mocks.user.getMetadata).toHaveBeenCalledWith(owner.id);
expect(mocks.user.getMetadata).toHaveBeenCalledWith(albumUser.userId);
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(owner.id, new Set([assetId]), false); expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(owner.id, new Set([assetId]), false);
expect(mocks.event.emit).toHaveBeenCalledWith('AlbumInvite', { expect(mocks.event.emit).toHaveBeenCalledWith('AlbumInvite', {
id: album.id, id: album.id,
@@ -290,11 +293,10 @@ describe(AlbumService.name, () => {
{ {
albumName: album.albumName, albumName: album.albumName,
description: album.description, description: album.description,
order: 'desc',
albumThumbnailAssetId: assetId, albumThumbnailAssetId: assetId,
}, },
[assetId], [assetId],
[{ userId: owner.id, role: AlbumUserRole.Owner }], [{ userId: owner.id, role: AlbumUserRole.Owner, order: 'desc' }],
owner.id, owner.id,
); );
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(owner.id, new Set([assetId, 'asset-2']), false); expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(owner.id, new Set([assetId, 'asset-2']), false);
@@ -364,10 +366,24 @@ describe(AlbumService.name, () => {
expect(mocks.album.update).toHaveBeenCalledTimes(1); expect(mocks.album.update).toHaveBeenCalledTimes(1);
expect(mocks.album.update).toHaveBeenCalledWith( expect(mocks.album.update).toHaveBeenCalledWith(
album.id, album.id,
{ id: album.id, albumName: 'new album name' }, expect.objectContaining({ id: album.id, albumName: 'new album name' }),
owner.id, owner.id,
undefined,
); );
}); });
it('should update the album order for the auth user', async () => {
const album = AlbumFactory.create({ order: AssetOrder.Desc });
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
mocks.album.getById.mockResolvedValue(getForAlbum(album));
mocks.album.update.mockResolvedValue(getForAlbum({ ...album, order: AssetOrder.Asc }));
await sut.update(AuthFactory.create(owner), album.id, { order: AssetOrder.Asc });
expect(mocks.album.update).toHaveBeenCalledTimes(1);
expect(mocks.album.update).toHaveBeenCalledWith(album.id, { id: album.id }, owner.id, AssetOrder.Asc);
});
}); });
describe('delete', () => { describe('delete', () => {
@@ -464,6 +480,7 @@ describe(AlbumService.name, () => {
mocks.album.getById.mockResolvedValue(getForAlbum(album)); mocks.album.getById.mockResolvedValue(getForAlbum(album));
mocks.album.update.mockResolvedValue(getForAlbum(album)); mocks.album.update.mockResolvedValue(getForAlbum(album));
mocks.user.get.mockResolvedValue(user); mocks.user.get.mockResolvedValue(user);
mocks.user.getMetadata.mockResolvedValue([]);
mocks.albumUser.create.mockResolvedValue(AlbumUserFactory.from().album(album).user(user).build()); mocks.albumUser.create.mockResolvedValue(AlbumUserFactory.from().album(album).user(user).build());
await sut.addUsers(AuthFactory.create(owner), album.id, { albumUsers: [{ userId: user.id }] }); await sut.addUsers(AuthFactory.create(owner), album.id, { albumUsers: [{ userId: user.id }] });
@@ -471,7 +488,9 @@ describe(AlbumService.name, () => {
expect(mocks.albumUser.create).toHaveBeenCalledWith({ expect(mocks.albumUser.create).toHaveBeenCalledWith({
userId: user.id, userId: user.id,
albumId: album.id, albumId: album.id,
order: AssetOrder.Desc,
}); });
expect(mocks.user.getMetadata).toHaveBeenCalledWith(user.id);
expect(mocks.event.emit).toHaveBeenCalledWith('AlbumInvite', { expect(mocks.event.emit).toHaveBeenCalledWith('AlbumInvite', {
id: album.id, id: album.id,
userId: user.id, userId: user.id,
+14 -4
View File
@@ -124,16 +124,22 @@ export class AlbumService extends BaseService {
const assetIds = [...allowedAssetIdsSet].map((id) => id); const assetIds = [...allowedAssetIdsSet].map((id) => id);
const userMetadata = await this.userRepository.getMetadata(auth.user.id); const userMetadata = await this.userRepository.getMetadata(auth.user.id);
const ownerOrder = getPreferences(userMetadata).albums.defaultAssetOrder;
const albumUsersWithOrder = await Promise.all(
albumUsers.map(async (albumUser) => {
const userMetadata = await this.userRepository.getMetadata(albumUser.userId);
return { ...albumUser, order: getPreferences(userMetadata).albums.defaultAssetOrder };
}),
);
const album = await this.albumRepository.create( const album = await this.albumRepository.create(
{ {
albumName: dto.albumName, albumName: dto.albumName,
description: dto.description, description: dto.description,
albumThumbnailAssetId: assetIds[0] || null, albumThumbnailAssetId: assetIds[0] || null,
order: getPreferences(userMetadata).albums.defaultAssetOrder,
}, },
assetIds, assetIds,
[{ userId: auth.user.id, role: AlbumUserRole.Owner }, ...albumUsers], [{ userId: auth.user.id, role: AlbumUserRole.Owner, order: ownerOrder }, ...albumUsersWithOrder],
auth.user.id, auth.user.id,
); );
@@ -155,6 +161,7 @@ export class AlbumService extends BaseService {
throw new BadRequestException('Invalid album thumbnail'); throw new BadRequestException('Invalid album thumbnail');
} }
} }
const updatedAlbum = await this.albumRepository.update( const updatedAlbum = await this.albumRepository.update(
album.id, album.id,
{ {
@@ -163,9 +170,9 @@ export class AlbumService extends BaseService {
description: dto.description, description: dto.description,
albumThumbnailAssetId: dto.albumThumbnailAssetId, albumThumbnailAssetId: dto.albumThumbnailAssetId,
isActivityEnabled: dto.isActivityEnabled, isActivityEnabled: dto.isActivityEnabled,
order: dto.order,
}, },
auth.user.id, auth.user.id,
dto.order,
); );
return mapAlbum({ ...updatedAlbum, assets: album.assets }); return mapAlbum({ ...updatedAlbum, assets: album.assets });
@@ -307,7 +314,10 @@ export class AlbumService extends BaseService {
throw new BadRequestException('Invalid user'); throw new BadRequestException('Invalid user');
} }
await this.albumUserRepository.create({ userId, albumId: id, role }); const userMetadata = await this.userRepository.getMetadata(userId);
const order = getPreferences(userMetadata).albums.defaultAssetOrder;
await this.albumUserRepository.create({ userId, albumId: id, role, order });
await this.eventRepository.emit('AlbumInvite', { id, userId, senderName: auth.user.name }); await this.eventRepository.emit('AlbumInvite', { id, userId, senderName: auth.user.name });
} }
@@ -1,4 +1,3 @@
import { BadRequestException } from '@nestjs/common';
import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto'; import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto';
import { MapAsset } from 'src/dtos/asset-response.dto'; import { MapAsset } from 'src/dtos/asset-response.dto';
import { AssetType, AssetVisibility, JobName, JobStatus } from 'src/enum'; import { AssetType, AssetVisibility, JobName, JobStatus } from 'src/enum';
@@ -150,36 +149,6 @@ describe(DuplicateService.name, () => {
}); });
}); });
describe('delete', () => {
it('should throw for an unknown or unauthorized group id', async () => {
mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set());
await expect(sut.delete(authStub.admin, 'group-1')).rejects.toThrow(BadRequestException);
expect(mocks.duplicateRepository.delete).not.toHaveBeenCalled();
});
it('should dismiss the duplicate group', async () => {
mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['group-1']));
mocks.duplicateRepository.delete.mockResolvedValue();
await expect(sut.delete(authStub.admin, 'group-1')).resolves.toBeUndefined();
expect(mocks.duplicateRepository.delete).toHaveBeenCalledWith(authStub.admin.user.id, 'group-1');
});
});
describe('deleteAll', () => {
it('should throw if any group id is unknown or unauthorized', async () => {
mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['group-1']));
await expect(sut.deleteAll(authStub.admin, { ids: ['group-1', 'group-2'] })).rejects.toThrow(BadRequestException);
expect(mocks.duplicateRepository.deleteAll).not.toHaveBeenCalled();
});
it('should dismiss all duplicate groups', async () => {
mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['group-1', 'group-2']));
mocks.duplicateRepository.deleteAll.mockResolvedValue();
await expect(sut.deleteAll(authStub.admin, { ids: ['group-1', 'group-2'] })).resolves.toBeUndefined();
expect(mocks.duplicateRepository.deleteAll).toHaveBeenCalledWith(authStub.admin.user.id, ['group-1', 'group-2']);
});
});
describe('resolve', () => { describe('resolve', () => {
it('should handle mixed success and failure', async () => { it('should handle mixed success and failure', async () => {
const asset = AssetFactory.create(); const asset = AssetFactory.create();
-2
View File
@@ -82,12 +82,10 @@ export class DuplicateService extends BaseService {
} }
async delete(auth: AuthDto, id: string): Promise<void> { async delete(auth: AuthDto, id: string): Promise<void> {
await this.requireAccess({ auth, permission: Permission.DuplicateDelete, ids: [id] });
await this.duplicateRepository.delete(auth.user.id, id); await this.duplicateRepository.delete(auth.user.id, id);
} }
async deleteAll(auth: AuthDto, dto: BulkIdsDto) { async deleteAll(auth: AuthDto, dto: BulkIdsDto) {
await this.requireAccess({ auth, permission: Permission.DuplicateDelete, ids: dto.ids });
await this.duplicateRepository.deleteAll(auth.user.id, dto.ids); await this.duplicateRepository.deleteAll(auth.user.id, dto.ids);
} }
+2 -1
View File
@@ -1,5 +1,5 @@
import { Selectable } from 'kysely'; import { Selectable } from 'kysely';
import { AlbumUserRole } from 'src/enum'; import { AlbumUserRole, AssetOrder } from 'src/enum';
import { AlbumUserTable } from 'src/schema/tables/album-user.table'; import { AlbumUserTable } from 'src/schema/tables/album-user.table';
import { AlbumFactory } from 'test/factories/album.factory'; import { AlbumFactory } from 'test/factories/album.factory';
import { build } from 'test/factories/builder.factory'; import { build } from 'test/factories/builder.factory';
@@ -24,6 +24,7 @@ export class AlbumUserFactory {
albumId: newUuid(), albumId: newUuid(),
userId: newUuid(), userId: newUuid(),
role: AlbumUserRole.Editor, role: AlbumUserRole.Editor,
order: AssetOrder.Desc,
createId: newUuidV7(), createId: newUuidV7(),
createdAt: newDate(), createdAt: newDate(),
updateId: newUuidV7(), updateId: newUuidV7(),
+1 -1
View File
@@ -15,7 +15,7 @@ export class AlbumFactory {
#albumUsers: AlbumUserFactory[] = []; #albumUsers: AlbumUserFactory[] = [];
#assets: AssetFactory[] = []; #assets: AssetFactory[] = [];
private constructor(private readonly value: Selectable<AlbumTable>) {} private constructor(private readonly value: Selectable<AlbumTable> & { order: AssetOrder }) {}
static create(dto: AlbumLike = {}) { static create(dto: AlbumLike = {}) {
return AlbumFactory.from(dto).build(); return AlbumFactory.from(dto).build();
+2 -1
View File
@@ -1,4 +1,5 @@
import { Selectable } from 'kysely'; import { Selectable } from 'kysely';
import { AssetOrder } from 'src/enum';
import { OAuthProfile } from 'src/repositories/oauth.repository'; import { OAuthProfile } from 'src/repositories/oauth.repository';
import { ActivityTable } from 'src/schema/tables/activity.table'; import { ActivityTable } from 'src/schema/tables/activity.table';
import { AlbumUserTable } from 'src/schema/tables/album-user.table'; import { AlbumUserTable } from 'src/schema/tables/album-user.table';
@@ -23,7 +24,7 @@ export type AssetLike = Partial<Selectable<AssetTable>>;
export type AssetExifLike = Partial<Selectable<AssetExifTable>>; export type AssetExifLike = Partial<Selectable<AssetExifTable>>;
export type AssetEditLike = Partial<Selectable<AssetEditTable>>; export type AssetEditLike = Partial<Selectable<AssetEditTable>>;
export type AssetFileLike = Partial<Selectable<AssetFileTable>>; export type AssetFileLike = Partial<Selectable<AssetFileTable>>;
export type AlbumLike = Partial<Selectable<AlbumTable>>; export type AlbumLike = Partial<Selectable<AlbumTable> & { order: AssetOrder }>;
export type AlbumUserLike = Partial<Selectable<AlbumUserTable>>; export type AlbumUserLike = Partial<Selectable<AlbumUserTable>>;
export type SharedLinkLike = Partial<Selectable<SharedLinkTable>>; export type SharedLinkLike = Partial<Selectable<SharedLinkTable>>;
export type UserLike = Partial<Selectable<UserTable>>; export type UserLike = Partial<Selectable<UserTable>>;
-4
View File
@@ -25,10 +25,6 @@ export const errorDto = {
badRequest: (message: any = null) => ({ badRequest: (message: any = null) => ({
message: message ?? expect.anything(), message: message ?? expect.anything(),
}), }),
validationError: (errors?: ReadonlyArray<{ path: ReadonlyArray<string | number>; message: string }>) => ({
message: 'Validation failed',
errors: errors ? expect.arrayContaining(errors.map((e) => expect.objectContaining(e))) : expect.any(Array),
}),
noPermission: { noPermission: {
message: expect.stringContaining('Not found or no'), message: expect.stringContaining('Not found or no'),
}, },
-5
View File
@@ -3,7 +3,6 @@ import { AuthDto } from 'src/dtos/auth.dto';
import { QueueStatisticsDto } from 'src/dtos/queue.dto'; import { QueueStatisticsDto } from 'src/dtos/queue.dto';
import { AssetFileType, Permission, UserStatus } from 'src/enum'; import { AssetFileType, Permission, UserStatus } from 'src/enum';
import { v4, v7 } from 'uuid'; import { v4, v7 } from 'uuid';
import { expect } from 'vitest';
export const newUuid = () => v4(); export const newUuid = () => v4();
export const newUuids = () => export const newUuids = () =>
@@ -249,9 +248,5 @@ export const factory = {
badRequest: (message: any = null) => ({ badRequest: (message: any = null) => ({
message: message ?? expect.anything(), message: message ?? expect.anything(),
}), }),
validationError: (errors?: ReadonlyArray<{ path: ReadonlyArray<string | number>; message: string }>) => ({
message: 'Validation failed',
errors: errors ? expect.arrayContaining(errors.map((e) => expect.objectContaining(e))) : expect.any(Array),
}),
}, },
}; };
@@ -284,11 +284,7 @@
{/snippet} {/snippet}
</AdaptiveImage> </AdaptiveImage>
{#if assetViewerManager.isFaceEditMode && assetViewerManager.imgRef && asset.width && asset.height} {#if assetViewerManager.isFaceEditMode && assetViewerManager.imgRef}
<FaceEditor <FaceEditor htmlElement={assetViewerManager.imgRef} {containerWidth} {containerHeight} assetId={asset.id} />
assetSize={{ width: asset.width, height: asset.height }}
containerSize={{ width: containerWidth, height: containerHeight }}
assetId={asset.id}
/>
{/if} {/if}
</div> </div>
@@ -140,40 +140,9 @@
let containerHeight = $state(0); let containerHeight = $state(0);
$effect(() => { $effect(() => {
if (!assetViewerManager.isFaceEditMode || !videoPlayer) { if (assetViewerManager.isFaceEditMode) {
return; videoPlayer?.pause();
} }
videoPlayer.pause();
const { videoWidth, videoHeight } = videoPlayer;
if (videoWidth === 0 || videoHeight === 0) {
return;
}
const canvas = document.createElement('canvas');
canvas.width = videoWidth;
canvas.height = videoHeight;
const context = canvas.getContext('2d');
if (!context) {
return;
}
context.drawImage(videoPlayer, 0, 0);
const dataUrl = canvas.toDataURL('image/png');
canvas.width = 0;
const img = new Image();
const onLoad = () => {
assetViewerManager.imgRef = img;
};
img.addEventListener('load', onLoad);
img.src = dataUrl;
return () => {
img.removeEventListener('load', onLoad);
img.src = '';
assetViewerManager.imgRef = undefined;
};
}); });
</script> </script>
@@ -279,11 +248,7 @@
{/if} {/if}
{#if assetViewerManager.isFaceEditMode} {#if assetViewerManager.isFaceEditMode}
<FaceEditor <FaceEditor htmlElement={videoPlayer} {containerWidth} {containerHeight} {assetId} />
assetSize={{ width: asset.width ?? 0, height: asset.height ?? 0 }}
containerSize={{ width: containerWidth, height: containerHeight }}
{assetId}
/>
{/if} {/if}
{/if} {/if}
</div> </div>
@@ -4,7 +4,7 @@
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import FaceCreateTagModal from '$lib/modals/CreateFaceModal.svelte'; import FaceCreateTagModal from '$lib/modals/CreateFaceModal.svelte';
import { getPeopleThumbnailUrl } from '$lib/utils'; import { getPeopleThumbnailUrl } from '$lib/utils';
import { computeContentMetrics, mapContentRectToNatural, type Size } from '$lib/utils/container-utils'; import { getNaturalSize, scaleToFit } from '$lib/utils/container-utils';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { createFace, getAllPeople, type PersonResponseDto } from '@immich/sdk'; import { createFace, getAllPeople, type PersonResponseDto } from '@immich/sdk';
import { Button, Input, modalManager, toastManager } from '@immich/ui'; import { Button, Input, modalManager, toastManager } from '@immich/ui';
@@ -14,12 +14,13 @@
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
type Props = { type Props = {
assetSize: Size; htmlElement: HTMLImageElement | HTMLVideoElement;
containerSize: Size; containerWidth: number;
containerHeight: number;
assetId: string; assetId: string;
}; };
let { assetSize, containerSize, assetId }: Props = $props(); let { htmlElement, containerWidth, containerHeight, assetId }: Props = $props();
let canvasEl: HTMLCanvasElement | undefined = $state(); let canvasEl: HTMLCanvasElement | undefined = $state();
let canvas: Canvas | undefined = $state(); let canvas: Canvas | undefined = $state();
@@ -53,7 +54,7 @@
}; };
const setupCanvas = () => { const setupCanvas = () => {
if (!canvasEl) { if (!canvasEl || !htmlElement) {
return; return;
} }
@@ -85,14 +86,24 @@
searchInputEl?.focus(); searchInputEl?.focus();
}); });
const imageContentMetrics = $derived(computeContentMetrics(assetSize, containerSize)); const imageContentMetrics = $derived.by(() => {
const natural = getNaturalSize(htmlElement);
const container = { width: containerWidth, height: containerHeight };
const { width: contentWidth, height: contentHeight } = scaleToFit(natural, container);
return {
contentWidth,
contentHeight,
offsetX: (containerWidth - contentWidth) / 2,
offsetY: (containerHeight - contentHeight) / 2,
};
});
const setDefaultFaceRectanglePosition = (faceRect: Rect) => { const setDefaultFaceRectanglePosition = (faceRect: Rect) => {
const { offsetX, offsetY, contentWidth, contentHeight } = imageContentMetrics; const { offsetX, offsetY } = imageContentMetrics;
faceRect.set({ faceRect.set({
top: offsetY + contentHeight / 2 - 56, top: offsetY + 200,
left: offsetX + contentWidth / 2 - 56, left: offsetX + 200,
}); });
faceRect.setCoords(); faceRect.setCoords();
@@ -105,8 +116,8 @@
} }
canvas.setDimensions({ canvas.setDimensions({
width: containerSize.width, width: containerWidth,
height: containerSize.height, height: containerHeight,
}); });
if (!faceRect) { if (!faceRect) {
@@ -156,9 +167,6 @@
const gap = 15; const gap = 15;
const padding = faceRect.padding ?? 0; const padding = faceRect.padding ?? 0;
const rawBox = faceRect.getBoundingRect(); const rawBox = faceRect.getBoundingRect();
if (Number.isNaN(rawBox.left) || Number.isNaN(rawBox.width)) {
return;
}
const faceBox = { const faceBox = {
left: rawBox.left - padding, left: rawBox.left - padding,
top: rawBox.top - padding, top: rawBox.top - padding,
@@ -167,11 +175,11 @@
}; };
const selectorWidth = faceSelectorEl.offsetWidth; const selectorWidth = faceSelectorEl.offsetWidth;
const chromeHeight = faceSelectorEl.offsetHeight - scrollableListEl.offsetHeight; const chromeHeight = faceSelectorEl.offsetHeight - scrollableListEl.offsetHeight;
const listHeight = Math.min(MAX_LIST_HEIGHT, containerSize.height - gap * 2 - chromeHeight); const listHeight = Math.min(MAX_LIST_HEIGHT, containerHeight - gap * 2 - chromeHeight);
const selectorHeight = listHeight + chromeHeight; const selectorHeight = listHeight + chromeHeight;
const clampTop = (top: number) => clamp(top, gap, containerSize.height - selectorHeight - gap); const clampTop = (top: number) => clamp(top, gap, containerHeight - selectorHeight - gap);
const clampLeft = (left: number) => clamp(left, gap, containerSize.width - selectorWidth - gap); const clampLeft = (left: number) => clamp(left, gap, containerWidth - selectorWidth - gap);
const overlapArea = (position: { top: number; left: number }) => { const overlapArea = (position: { top: number; left: number }) => {
const selectorRight = position.left + selectorWidth; const selectorRight = position.left + selectorWidth;
@@ -230,37 +238,45 @@
}); });
const getFaceCroppedCoordinates = () => { const getFaceCroppedCoordinates = () => {
if (!faceRect || imageContentMetrics.contentWidth === 0) { if (!faceRect || !htmlElement) {
return; return;
} }
const imageRect = mapContentRectToNatural(faceRect.getBoundingRect(), imageContentMetrics, assetSize); const { left, top, width, height } = faceRect.getBoundingRect();
const { offsetX, offsetY, contentWidth, contentHeight } = imageContentMetrics;
const natural = getNaturalSize(htmlElement);
const scaleX = natural.width / contentWidth;
const scaleY = natural.height / contentHeight;
const imageX = (left - offsetX) * scaleX;
const imageY = (top - offsetY) * scaleY;
return { return {
imageWidth: assetSize.width, imageWidth: natural.width,
imageHeight: assetSize.height, imageHeight: natural.height,
x: Math.floor(imageRect.left), x: Math.floor(imageX),
y: Math.floor(imageRect.top), y: Math.floor(imageY),
width: Math.floor(imageRect.width), width: Math.floor(width * scaleX),
height: Math.floor(imageRect.height), height: Math.floor(height * scaleY),
}; };
}; };
type FaceCoordinates = NonNullable<ReturnType<typeof getFaceCroppedCoordinates>>; type FaceCoordinates = NonNullable<ReturnType<typeof getFaceCroppedCoordinates>>;
const getFacePreviewUrl = (data: FaceCoordinates) => { const getFacePreviewUrl = (data: FaceCoordinates) => {
const imgRef = assetViewerManager.imgRef; if (!htmlElement) {
if (!imgRef || imageContentMetrics.contentWidth === 0) {
return; return;
} }
const scaleX = imgRef.naturalWidth / assetSize.width; const natural = getNaturalSize(htmlElement);
const scaleY = imgRef.naturalHeight / assetSize.height; if (natural.width <= 0 || natural.height <= 0) {
return;
}
const x = clamp(Math.floor(data.x * scaleX), 0, imgRef.naturalWidth - 1); const x = clamp(data.x, 0, natural.width - 1);
const y = clamp(Math.floor(data.y * scaleY), 0, imgRef.naturalHeight - 1); const y = clamp(data.y, 0, natural.height - 1);
const width = clamp(Math.floor(data.width * scaleX), 1, imgRef.naturalWidth - x); const width = clamp(data.width, 1, natural.width - x);
const height = clamp(Math.floor(data.height * scaleY), 1, imgRef.naturalHeight - y); const height = clamp(data.height, 1, natural.height - y);
if (width <= 0 || height <= 0) { if (width <= 0 || height <= 0) {
return; return;
@@ -276,7 +292,7 @@
} }
try { try {
context.drawImage(imgRef, x, y, width, height, 0, 0, width, height); context.drawImage(htmlElement, x, y, width, height, 0, 0, width, height);
return canvas.toDataURL('image/png'); return canvas.toDataURL('image/png');
} catch { } catch {
return; return;
+33 -67
View File
@@ -1,15 +1,18 @@
import { import {
computeContentMetrics, getContentMetrics,
getNaturalSize, getNaturalSize,
mapContentRectToNatural,
mapNormalizedRectToContent, mapNormalizedRectToContent,
mapNormalizedToContent, mapNormalizedToContent,
scaleToCover, scaleToCover,
scaleToFit, scaleToFit,
} from '$lib/utils/container-utils'; } from '$lib/utils/container-utils';
const mockImage = (props: { naturalWidth: number; naturalHeight: number }): HTMLImageElement => const mockImage = (props: {
props as unknown as HTMLImageElement; naturalWidth: number;
naturalHeight: number;
width: number;
height: number;
}): HTMLImageElement => props as unknown as HTMLImageElement;
const mockVideo = (props: { const mockVideo = (props: {
videoWidth: number; videoWidth: number;
@@ -46,85 +49,48 @@ describe('scaleToFit', () => {
}); });
}); });
describe('computeContentMetrics', () => { describe('getContentMetrics', () => {
it('should return zero metrics for zero-width content', () => { it('should compute zero offsets when aspect ratios match', () => {
expect(computeContentMetrics({ width: 0, height: 1080 }, { width: 800, height: 600 })).toEqual({ const img = mockImage({ naturalWidth: 1600, naturalHeight: 900, width: 800, height: 450 });
contentWidth: 0, expect(getContentMetrics(img)).toEqual({
contentHeight: 0,
offsetX: 0,
offsetY: 0,
});
});
it('should return zero metrics for zero-height content', () => {
expect(computeContentMetrics({ width: 1920, height: 0 }, { width: 800, height: 600 })).toEqual({
contentWidth: 0,
contentHeight: 0,
offsetX: 0,
offsetY: 0,
});
});
it('should center wide content vertically', () => {
expect(computeContentMetrics({ width: 2000, height: 1000 }, { width: 800, height: 600 })).toEqual({
contentWidth: 800,
contentHeight: 400,
offsetX: 0,
offsetY: 100,
});
});
it('should center tall content horizontally', () => {
expect(computeContentMetrics({ width: 1000, height: 2000 }, { width: 800, height: 600 })).toEqual({
contentWidth: 300,
contentHeight: 600,
offsetX: 250,
offsetY: 0,
});
});
it('should produce zero offsets when aspect ratios match', () => {
expect(computeContentMetrics({ width: 1600, height: 900 }, { width: 800, height: 450 })).toEqual({
contentWidth: 800, contentWidth: 800,
contentHeight: 450, contentHeight: 450,
offsetX: 0, offsetX: 0,
offsetY: 0, offsetY: 0,
}); });
}); });
});
describe('mapContentRectToNatural', () => { it('should compute horizontal letterbox offsets for tall image', () => {
it('should map a full-content rect back to natural size', () => { const img = mockImage({ naturalWidth: 1000, naturalHeight: 2000, width: 800, height: 600 });
const metrics = { contentWidth: 800, contentHeight: 400, offsetX: 0, offsetY: 100 }; const metrics = getContentMetrics(img);
const rect = mapContentRectToNatural({ left: 0, top: 100, width: 800, height: 400 }, metrics, { expect(metrics.contentWidth).toBe(300);
width: 2000, expect(metrics.contentHeight).toBe(600);
height: 1000, expect(metrics.offsetX).toBe(250);
}); expect(metrics.offsetY).toBe(0);
expect(rect).toEqual({ left: 0, top: 0, width: 2000, height: 1000 });
}); });
it('should map a centered sub-rect to natural coordinates', () => { it('should compute vertical letterbox offsets for wide image', () => {
const metrics = { contentWidth: 800, contentHeight: 400, offsetX: 0, offsetY: 100 }; const img = mockImage({ naturalWidth: 2000, naturalHeight: 1000, width: 800, height: 600 });
const rect = mapContentRectToNatural({ left: 200, top: 200, width: 400, height: 200 }, metrics, { const metrics = getContentMetrics(img);
width: 2000, expect(metrics.contentWidth).toBe(800);
height: 1000, expect(metrics.contentHeight).toBe(400);
}); expect(metrics.offsetX).toBe(0);
expect(rect).toEqual({ left: 500, top: 250, width: 1000, height: 500 }); expect(metrics.offsetY).toBe(100);
}); });
it('should handle letterboxed content with horizontal offset', () => { it('should use clientWidth/clientHeight for video elements', () => {
const metrics = { contentWidth: 300, contentHeight: 600, offsetX: 250, offsetY: 0 }; const video = mockVideo({ videoWidth: 1920, videoHeight: 1080, clientWidth: 800, clientHeight: 600 });
const rect = mapContentRectToNatural({ left: 250, top: 0, width: 300, height: 600 }, metrics, { const metrics = getContentMetrics(video);
width: 1000, expect(metrics.contentWidth).toBe(800);
height: 2000, expect(metrics.contentHeight).toBe(450);
}); expect(metrics.offsetX).toBe(0);
expect(rect).toEqual({ left: 0, top: 0, width: 1000, height: 2000 }); expect(metrics.offsetY).toBe(75);
}); });
}); });
describe('getNaturalSize', () => { describe('getNaturalSize', () => {
it('should return naturalWidth/naturalHeight for images', () => { it('should return naturalWidth/naturalHeight for images', () => {
const img = mockImage({ naturalWidth: 4000, naturalHeight: 3000 }); const img = mockImage({ naturalWidth: 4000, naturalHeight: 3000, width: 800, height: 600 });
expect(getNaturalSize(img)).toEqual({ width: 4000, height: 3000 }); expect(getNaturalSize(img)).toEqual({ width: 4000, height: 3000 });
}); });
+14 -30
View File
@@ -49,6 +49,13 @@ export const scaleToFit = (dimensions: Size, container: Size): Size => {
}; };
}; };
const getElementSize = (element: HTMLImageElement | HTMLVideoElement): Size => {
if (element instanceof HTMLVideoElement) {
return { width: element.clientWidth, height: element.clientHeight };
}
return { width: element.width, height: element.height };
};
export const getNaturalSize = (element: HTMLImageElement | HTMLVideoElement): Size => { export const getNaturalSize = (element: HTMLImageElement | HTMLVideoElement): Size => {
if (element instanceof HTMLVideoElement) { if (element instanceof HTMLVideoElement) {
return { width: element.videoWidth, height: element.videoHeight }; return { width: element.videoWidth, height: element.videoHeight };
@@ -56,18 +63,17 @@ export const getNaturalSize = (element: HTMLImageElement | HTMLVideoElement): Si
return { width: element.naturalWidth, height: element.naturalHeight }; return { width: element.naturalWidth, height: element.naturalHeight };
}; };
export function computeContentMetrics(content: Size, container: Size): ContentMetrics { export const getContentMetrics = (element: HTMLImageElement | HTMLVideoElement): ContentMetrics => {
if (content.width === 0 || content.height === 0) { const natural = getNaturalSize(element);
return { contentWidth: 0, contentHeight: 0, offsetX: 0, offsetY: 0 }; const client = getElementSize(element);
} const { width: contentWidth, height: contentHeight } = scaleToFit(natural, client);
const { width: contentWidth, height: contentHeight } = scaleToFit(content, container);
return { return {
contentWidth, contentWidth,
contentHeight, contentHeight,
offsetX: (container.width - contentWidth) / 2, offsetX: (client.width - contentWidth) / 2,
offsetY: (container.height - contentHeight) / 2, offsetY: (client.height - contentHeight) / 2,
}; };
} };
export function mapNormalizedToContent(point: Point, sizeOrMetrics: Size | ContentMetrics): Point { export function mapNormalizedToContent(point: Point, sizeOrMetrics: Size | ContentMetrics): Point {
if ('contentWidth' in sizeOrMetrics) { if ('contentWidth' in sizeOrMetrics) {
@@ -103,25 +109,3 @@ export function mapNormalizedRectToContent(
height: br.y - tl.y, height: br.y - tl.y,
}; };
} }
function mapContentToNatural(point: Point, metrics: ContentMetrics, naturalSize: Size): Point {
return {
x: ((point.x - metrics.offsetX) / metrics.contentWidth) * naturalSize.width,
y: ((point.y - metrics.offsetY) / metrics.contentHeight) * naturalSize.height,
};
}
export function mapContentRectToNatural(rect: Rect, metrics: ContentMetrics, naturalSize: Size): Rect {
const topLeft = mapContentToNatural({ x: rect.left, y: rect.top }, metrics, naturalSize);
const bottomRight = mapContentToNatural(
{ x: rect.left + rect.width, y: rect.top + rect.height },
metrics,
naturalSize,
);
return {
top: topLeft.y,
left: topLeft.x,
width: bottomRight.x - topLeft.x,
height: bottomRight.y - topLeft.y,
};
}
-12
View File
@@ -16,18 +16,6 @@ export function getServerErrorMessage(error: unknown) {
} }
} }
if (Array.isArray(data?.errors) && data.errors.length > 0) {
const details = data.errors
.map(({ path, message }) => {
const field = path
.map((segment, i) => (typeof segment === 'number' ? `[${segment}]` : i === 0 ? segment : `.${segment}`))
.join('');
return field ? `${field}: ${message}` : message;
})
.join(', ');
return `${data.message}: ${details}`;
}
return data?.message || error.message; return data?.message || error.message;
} }
-10
View File
@@ -7,9 +7,6 @@ describe('formatGroupTitle', () => {
beforeAll(() => { beforeAll(() => {
vi.useFakeTimers(); vi.useFakeTimers();
process.env.TZ = 'UTC'; process.env.TZ = 'UTC';
});
beforeEach(() => {
vi.setSystemTime(new Date('2024-07-27T12:00:00Z')); vi.setSystemTime(new Date('2024-07-27T12:00:00Z'));
}); });
@@ -34,13 +31,6 @@ describe('formatGroupTitle', () => {
expect(formatGroupTitle(date)).toBe('hier'); expect(formatGroupTitle(date)).toBe('hier');
}); });
it('formats yesterday across month boundaries', () => {
vi.setSystemTime(new Date('2024-05-01T12:00:00Z'));
const date = parseUtcDate('2024-04-30T23:59:59Z');
locale.set('en');
expect(formatGroupTitle(date)).toBe('yesterday');
});
it('formats last week', () => { it('formats last week', () => {
const date = parseUtcDate('2024-07-21T00:00:00Z'); const date = parseUtcDate('2024-07-21T00:00:00Z');
locale.set('en'); locale.set('en');
+1 -1
View File
@@ -128,7 +128,7 @@ export function formatGroupTitle(_date: DateTime): string {
// Yesterday // Yesterday
if (today.minus({ days: 1 }).hasSame(date, 'day')) { if (today.minus({ days: 1 }).hasSame(date, 'day')) {
return date.toRelativeCalendar({ locale: get(locale), unit: 'days' }); return date.toRelativeCalendar({ locale: get(locale) });
} }
// Last week // Last week