Compare commits

..

6 Commits

Author SHA1 Message Date
midzelis c8d0597359 fix(web): Create Person face preview not working for video assets
FaceEditor previously required an HTMLImageElement | HTMLVideoElement prop to
compute layout metrics and generate the face crop preview. This was unavailable
for video assets, so the preview thumbnail in the Create Person modal was always
missing, and face positions could be NaN during image load (naturalWidth is 0
before the image decodes).

Replace the DOM element prop with assetSize: Size and containerSize: Size, using
asset metadata dimensions that are always available from the API response.
computeContentMetrics() is extracted as a pure utility alongside
mapContentRectToNatural() for converting face rect coordinates back to original
image space.

For videos, VideoNativeViewer now captures the current frame to canvas when face
edit mode opens and sets assetViewerManager.imgRef, giving FaceEditor the same
image-based preview path as photo assets.

Change-Id: I0e9da549e3af40211abad4ab2c0270706a6a6964
2026-05-04 14:20:29 +00:00
Michel Heusschen 2015f95ff5 fix(web): correct timeline yesterday label across month boundaries (#28183) 2026-05-04 13:46:11 +02:00
Timon d4f29ab6ac fix(server): validate duplicate group ownership before dismissal (#28221) 2026-05-04 12:51:54 +02:00
Timon 3decc864b5 refactor(server)!: structured validation error responses (#28204)
* refactor(server)!: structured validation error responses

* refactor(server): clarify comment on removing duplicate HTTP response fields

* enhance validation error tests

* make path and message required

* fmt

* fix e2e test

* fmt

* feat: enhance error handling in getServerErrorMessage function
2026-05-04 00:00:03 -04:00
David Allen eca0e60db8 fix: librknnrt permissions in machine-learning (#28216)
fix librknnrt permissions in machine-learning
2026-05-03 23:39:27 +00:00
AyaanMAG 8cff5883b5 fix(ml): respect time zone for logs in cuda container (#28155) 2026-05-03 04:19:56 +00:00
60 changed files with 724 additions and 437 deletions
+4 -3
View File
@@ -28,6 +28,10 @@ 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'),
}, },
@@ -37,9 +41,6 @@ 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 = {
+21 -7
View File
@@ -110,7 +110,9 @@ describe('/libraries', () => {
}); });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[importPaths] Array must have unique items'])); expect(body).toEqual(
errorDto.validationError([{ path: ['importPaths'], message: 'Array must have unique items' }]),
);
}); });
it('should not create an external library with duplicate exclusion patterns', async () => { it('should not create an external library with duplicate exclusion patterns', async () => {
@@ -125,7 +127,9 @@ describe('/libraries', () => {
}); });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[exclusionPatterns] Array must have unique items'])); expect(body).toEqual(
errorDto.validationError([{ path: ['exclusionPatterns'], message: 'Array must have unique items' }]),
);
}); });
}); });
@@ -157,7 +161,9 @@ describe('/libraries', () => {
.send({ name: '' }); .send({ name: '' });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[name] Too small: expected string to have >=1 characters'])); expect(body).toEqual(
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 () => {
@@ -181,7 +187,9 @@ describe('/libraries', () => {
.send({ importPaths: [''] }); .send({ importPaths: [''] });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[importPaths] Array items must not be empty'])); expect(body).toEqual(
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 () => {
@@ -191,7 +199,9 @@ describe('/libraries', () => {
.send({ importPaths: ['/path', '/path'] }); .send({ importPaths: ['/path', '/path'] });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[importPaths] Array must have unique items'])); expect(body).toEqual(
errorDto.validationError([{ path: ['importPaths'], message: 'Array must have unique items' }]),
);
}); });
it('should change the exclusion pattern', async () => { it('should change the exclusion pattern', async () => {
@@ -215,7 +225,9 @@ describe('/libraries', () => {
.send({ exclusionPatterns: ['**/*.jpg', '**/*.jpg'] }); .send({ exclusionPatterns: ['**/*.jpg', '**/*.jpg'] });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[exclusionPatterns] Array must have unique items'])); expect(body).toEqual(
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 () => {
@@ -225,7 +237,9 @@ describe('/libraries', () => {
.send({ exclusionPatterns: [''] }); .send({ exclusionPatterns: [''] });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[exclusionPatterns] Array items must not be empty'])); expect(body).toEqual(
errorDto.validationError([{ path: ['exclusionPatterns'], message: 'Array items must not be empty' }]),
);
}); });
}); });
+12 -4
View File
@@ -109,7 +109,9 @@ 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(errorDto.badRequest(['[lat] Invalid input: expected number, received NaN'])); expect(body).toEqual(
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 () => {
@@ -117,7 +119,9 @@ 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(errorDto.badRequest(['[lat] Invalid input: expected number, received NaN'])); expect(body).toEqual(
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 () => {
@@ -125,7 +129,9 @@ 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(errorDto.badRequest(['[lat] Too big: expected number to be <=90'])); expect(body).toEqual(
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 () => {
@@ -133,7 +139,9 @@ 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(errorDto.badRequest(['[lon] Invalid input: expected number, received NaN'])); expect(body).toEqual(
errorDto.validationError([{ path: ['lon'], message: 'Invalid input: expected number, received NaN' }]),
);
}); });
const reverseGeocodeTestCases = [ const reverseGeocodeTestCases = [
+16 -4
View File
@@ -105,7 +105,11 @@ 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(errorDto.badRequest(['[redirectUri] Invalid input: expected string, received undefined'])); expect(body).toEqual(
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 () => {
@@ -164,13 +168,17 @@ 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(errorDto.badRequest(['[url] Invalid input: expected string, received undefined'])); expect(body).toEqual(
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(errorDto.badRequest(['[url] Too small: expected string to have >=1 characters'])); expect(body).toEqual(
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 () => {
@@ -375,7 +383,11 @@ 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(errorDto.badRequest(['[logout_token] Invalid input: expected string, received undefined'])); expect(body).toEqual(
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,7 +341,9 @@ describe('/shared-links', () => {
.set('Authorization', `Bearer ${user1.accessToken}`); .set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest()); expect(body).toEqual(
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 () => {
+9 -2
View File
@@ -41,7 +41,9 @@ describe('/stacks', () => {
.send({ assetIds: [asset.id] }); .send({ assetIds: [asset.id] });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest()); expect(body).toEqual(
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 () => {
@@ -51,7 +53,12 @@ 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(errorDto.badRequest()); expect(body).toEqual(
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.badRequest(['[id] Invalid UUID'])); expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: '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.badRequest(['[id] Invalid UUID'])); expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
}); });
it('should delete a tag', async () => { it('should delete a tag', async () => {
@@ -108,14 +108,20 @@ describe('/admin/users', () => {
expect(body).toEqual(errorDto.forbidden); expect(body).toEqual(errorDto.forbidden);
}); });
for (const key of ['password', 'email', 'name', 'quotaSizeInBytes', 'shouldChangePassword', 'notify']) { for (const [key, message] of [
['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.badRequest()); expect(body).toEqual(errorDto.validationError([{ path: [key], message }]));
}); });
} }
@@ -153,14 +159,19 @@ describe('/admin/users', () => {
expect(body).toEqual(errorDto.forbidden); expect(body).toEqual(errorDto.forbidden);
}); });
for (const key of ['password', 'email', 'name', 'shouldChangePassword']) { for (const [key, message] of [
['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.badRequest()); expect(body).toEqual(errorDto.validationError([{ path: [key], message }]));
}); });
} }
+6 -2
View File
@@ -179,7 +179,9 @@ describe('/users', () => {
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(
errorDto.badRequest(['[download.archiveSize] Invalid input: expected int, received number']), errorDto.validationError([
{ path: ['download', 'archiveSize'], message: 'Invalid input: expected int, received number' },
]),
); );
}); });
@@ -207,7 +209,9 @@ describe('/users', () => {
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(
errorDto.badRequest(['[download.includeEmbeddedVideos] Invalid input: expected boolean, received number']), errorDto.validationError([
{ 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 && \ apt-get install --no-install-recommends -yqq libcudnn9-cuda-12=9.10.2.21-1 tzdata && \
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 --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 --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/
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} | Delete a duplicate *DuplicatesApi* | [**deleteDuplicate**](doc//DuplicatesApi.md#deleteduplicate) | **DELETE** /duplicates/{id} | Dismiss a duplicate group
*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;
/// Delete a duplicate /// Dismiss a duplicate group
/// ///
/// Delete a single duplicate asset specified by its ID. /// Dismiss a duplicate group by its ID, unlinking all assets in the group without deleting them.
/// ///
/// Note: This method returns the HTTP [Response]. /// Note: This method returns the HTTP [Response].
/// ///
@@ -51,9 +51,9 @@ class DuplicatesApi {
); );
} }
/// Delete a duplicate /// Dismiss a duplicate group
/// ///
/// Delete a single duplicate asset specified by its ID. /// Dismiss a duplicate group by its ID, unlinking all assets in the group without deleting them.
/// ///
/// Parameters: /// Parameters:
/// ///
+2 -2
View File
@@ -5172,7 +5172,7 @@
}, },
"/duplicates/{id}": { "/duplicates/{id}": {
"delete": { "delete": {
"description": "Delete a single duplicate asset specified by its ID.", "description": "Dismiss a duplicate group by its ID, unlinking all assets in the group without deleting them.",
"operationId": "deleteDuplicate", "operationId": "deleteDuplicate",
"parameters": [ "parameters": [
{ {
@@ -5202,7 +5202,7 @@
"api_key": [] "api_key": []
} }
], ],
"summary": "Delete a duplicate", "summary": "Dismiss a duplicate group",
"tags": [ "tags": [
"Duplicates" "Duplicates"
], ],
+1 -1
View File
@@ -4480,7 +4480,7 @@ export function resolveDuplicates({ duplicateResolveDto }: {
}))); })));
} }
/** /**
* Delete a duplicate * Dismiss a duplicate group
*/ */
export function deleteDuplicate({ id }: { export function deleteDuplicate({ id }: {
id: string; id: string;
@@ -1,9 +1,16 @@
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,14 +28,16 @@ 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.badRequest(['[albumId] Invalid input: expected string, received undefined']), factory.responses.validationError([
{ 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.badRequest(['[albumId] Invalid UUID'])); expect(body).toEqual(factory.responses.validationError([{ path: ['albumId'], message: 'Invalid UUID' }]));
}); });
it('should reject an invalid assetId', async () => { it('should reject an invalid assetId', async () => {
@@ -43,7 +45,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.badRequest(['[assetId] Invalid UUID'])); expect(body).toEqual(factory.responses.validationError([{ path: ['assetId'], message: 'Invalid UUID' }]));
}); });
}); });
@@ -58,7 +60,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.badRequest(['[albumId] Invalid UUID'])); expect(body).toEqual(factory.responses.validationError([{ path: ['albumId'], message: 'Invalid UUID' }]));
}); });
it('should require a comment when type is comment', async () => { it('should require a comment when type is comment', async () => {
@@ -66,7 +68,11 @@ 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(factory.responses.badRequest(['[comment] Invalid input: expected string, received null'])); expect(body).toEqual(
factory.responses.validationError([
{ path: ['comment'], message: 'Invalid input: expected string, received null' },
]),
);
}); });
}); });
@@ -79,7 +85,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.badRequest(['[id] Invalid UUID'])); expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
}); });
}); });
}); });
@@ -27,13 +27,17 @@ 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(factory.responses.badRequest(['[shared] Invalid option: expected one of "true"|"false"'])); expect(body).toEqual(
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.badRequest(['[assetId] Invalid UUID'])); expect(body).toEqual(factory.responses.validationError([{ path: ['assetId'], message: '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.badRequest(['[id] Invalid UUID'])); expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: '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.badRequest(['[id] Invalid UUID'])); expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: '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.badRequest(['[id] Invalid UUID'])); expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
}); });
}); });
}); });
@@ -80,7 +80,9 @@ describe(AssetMediaController.name, () => {
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(
factory.responses.badRequest(['[metadata] Invalid input: expected JSON string, received string']), factory.responses.validationError([
{ path: ['metadata'], message: 'Invalid input: expected JSON string, received string' },
]),
); );
}); });
@@ -91,8 +93,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.badRequest([ factory.responses.validationError([
'[fileCreatedAt] Invalid input: expected ISO 8601 datetime string, received undefined', { path: ['fileCreatedAt'], message: 'Invalid input: expected ISO 8601 datetime string, received undefined' },
]), ]),
); );
}); });
@@ -104,8 +106,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.badRequest([ factory.responses.validationError([
'[fileModifiedAt] Invalid input: expected ISO 8601 datetime string, received undefined', { path: ['fileModifiedAt'], message: 'Invalid input: expected ISO 8601 datetime string, received undefined' },
]), ]),
); );
}); });
@@ -117,7 +119,9 @@ 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.badRequest(['[isFavorite] Invalid option: expected one of "true"|"false"']), factory.responses.validationError([
{ path: ['isFavorite'], message: 'Invalid option: expected one of "true"|"false"' },
]),
); );
}); });
@@ -128,7 +132,9 @@ 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.badRequest([expect.stringContaining('[visibility] Invalid option: expected one of')]), factory.responses.validationError([
{ path: ['visibility'], message: expect.stringContaining('Invalid option: expected one of') },
]),
); );
}); });
+80 -44
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.badRequest(['[ids.0] Invalid UUID'])); expect(body).toEqual(factory.responses.validationError([{ path: ['ids', 0], message: 'Invalid UUID' }]));
}); });
it('should require duplicateId to be a string', async () => { it('should require duplicateId to be a string', async () => {
@@ -42,7 +42,9 @@ describe(AssetController.name, () => {
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(
factory.responses.badRequest(['[duplicateId] Invalid input: expected string, received boolean']), factory.responses.validationError([
{ path: ['duplicateId'], message: 'Invalid input: expected string, received boolean' },
]),
); );
}); });
@@ -70,7 +72,7 @@ describe(AssetController.name, () => {
.send({ ids: ['123'] }); .send({ ids: ['123'] });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(['[ids.0] Invalid UUID'])); expect(body).toEqual(factory.responses.validationError([{ path: ['ids', 0], message: 'Invalid UUID' }]));
}); });
}); });
@@ -83,7 +85,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.badRequest(['[id] Invalid UUID'])); expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
}); });
}); });
@@ -97,12 +99,10 @@ 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.badRequest( factory.responses.validationError([
expect.arrayContaining([ { path: ['sourceId'], message: 'Invalid input: expected string, received undefined' },
'[sourceId] Invalid input: expected string, received undefined', { path: ['targetId'], message: 'Invalid input: expected string, received undefined' },
'[targetId] Invalid input: expected string, received undefined',
]), ]),
),
); );
}); });
@@ -125,7 +125,9 @@ 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(factory.responses.badRequest(expect.arrayContaining(['[items.0.assetId] Invalid UUID']))); expect(body).toEqual(
factory.responses.validationError([{ path: ['items', 0, 'assetId'], message: 'Invalid UUID' }]),
);
}); });
it('should require a key', async () => { it('should require a key', async () => {
@@ -134,9 +136,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.badRequest( factory.responses.validationError([
expect.arrayContaining(['[items.0.key] Invalid input: expected string, received undefined']), { path: ['items', 0, 'key'], message: 'Invalid input: expected string, received undefined' },
), ]),
); );
}); });
@@ -159,7 +161,9 @@ 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(factory.responses.badRequest(expect.arrayContaining(['[items.0.assetId] Invalid UUID']))); expect(body).toEqual(
factory.responses.validationError([{ path: ['items', 0, 'assetId'], message: 'Invalid UUID' }]),
);
}); });
it('should require a key', async () => { it('should require a key', async () => {
@@ -168,9 +172,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.badRequest( factory.responses.validationError([
expect.arrayContaining(['[items.0.key] Invalid input: expected string, received undefined']), { path: ['items', 0, 'key'], message: 'Invalid input: expected string, received undefined' },
), ]),
); );
}); });
@@ -191,33 +195,56 @@ 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(factory.responses.badRequest(['Invalid input: expected object, received undefined'])); expect(body).toEqual(
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 of [ for (const [test, errors] of [
{ latitude: 12 }, [{ latitude: 12 }, [{ path: [], message: 'Latitude and longitude must be provided together' }]],
{ longitude: 12 }, [{ longitude: 12 }, [{ path: [], message: 'Latitude and longitude must be provided together' }]],
[
{ latitude: 12, longitude: 'abc' }, { latitude: 12, longitude: 'abc' },
[{ path: ['longitude'], message: 'Invalid input: expected number, received string' }],
],
[
{ latitude: 'abc', longitude: 12 }, { latitude: 'abc', longitude: 12 },
[{ path: ['latitude'], message: 'Invalid input: expected number, received string' }],
],
[
{ latitude: null, longitude: 12 }, { latitude: null, longitude: 12 },
[{ path: ['latitude'], message: 'Invalid input: expected number, received null' }],
],
[
{ latitude: 12, longitude: null }, { latitude: 12, longitude: null },
{ latitude: 91, longitude: 12 }, [{ path: ['longitude'], message: 'Invalid input: expected number, received null' }],
{ latitude: -91, longitude: 12 }, ],
[{ 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 }, { latitude: 12, longitude: -181 },
{ 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.badRequest()); expect(body).toEqual(factory.responses.validationError(errors));
} }
}); });
it('should reject invalid rating', async () => { it('should reject invalid rating', async () => {
for (const test of [{ rating: 7 }, { rating: 3.5 }, { rating: -2 }]) { for (const [test, errors] of [
[{ 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.badRequest()); expect(body).toEqual(factory.responses.validationError(errors));
} }
}); });
@@ -261,13 +288,17 @@ 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.badRequest(expect.arrayContaining(['[id] Invalid UUID']))); expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: '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(factory.responses.badRequest(['[items] Invalid input: expected array, received undefined'])); expect(body).toEqual(
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 () => {
@@ -276,7 +307,9 @@ 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.badRequest(['[items.0.key] Invalid input: expected string, received undefined']), factory.responses.validationError([
{ path: ['items', 0, 'key'], message: 'Invalid input: expected string, received undefined' },
]),
); );
}); });
@@ -286,9 +319,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.badRequest( factory.responses.validationError([
expect.arrayContaining(['[items.0.value] Invalid input: expected record, received null']), { path: ['items', 0, 'value'], message: 'Invalid input: expected record, received null' },
), ]),
); );
}); });
@@ -326,7 +359,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.badRequest(expect.arrayContaining(['[id] Invalid UUID']))); expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
}); });
}); });
@@ -376,7 +409,7 @@ describe(AssetController.name, () => {
}); });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['[id] Invalid UUID']))); expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
}); });
it('should check the action and parameters discriminator', async () => { it('should check the action and parameters discriminator', async () => {
@@ -398,13 +431,12 @@ describe(AssetController.name, () => {
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(
factory.responses.badRequest( factory.responses.validationError([
expect.arrayContaining([ {
expect.stringContaining( path: ['edits', 0, 'parameters'],
"[edits.0.parameters] Invalid parameters for action 'rotate', expecting keys: angle", message: expect.stringContaining("Invalid parameters for action 'rotate', expecting keys: angle"),
), },
]), ]),
),
); );
}); });
@@ -413,7 +445,11 @@ 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(factory.responses.badRequest(['[edits] Too small: expected array to have >=1 items'])); expect(body).toEqual(
factory.responses.validationError([
{ path: ['edits'], message: 'Too small: expected array to have >=1 items' },
]),
);
}); });
}); });
@@ -426,7 +462,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.badRequest(['[id] Invalid UUID'])); expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
}); });
}); });
}); });
+41 -13
View File
@@ -28,19 +28,27 @@ 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(errorDto.badRequest()); expect(body).toEqual(
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(errorDto.badRequest()); expect(body).toEqual(
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(errorDto.badRequest()); expect(body).toEqual(
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 () => {
@@ -48,7 +56,9 @@ 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(errorDto.badRequest()); expect(body).toEqual(
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 () => {
@@ -73,9 +83,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.badRequest([ errorDto.validationError([
'[email] Invalid input: expected email, received undefined', { path: ['email'], message: 'Invalid input: expected email, received undefined' },
'[password] Invalid input: expected string, received undefined', { path: ['password'], message: 'Invalid input: expected string, received undefined' },
]), ]),
); );
}); });
@@ -85,7 +95,9 @@ 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(errorDto.badRequest(['[email] Invalid input: expected email, received object'])); expect(body).toEqual(
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 () => {
@@ -93,7 +105,9 @@ 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(errorDto.badRequest(['[password] Invalid input: expected string, received null'])); expect(body).toEqual(
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 () => {
@@ -104,7 +118,9 @@ 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(errorDto.badRequest(['[email] Invalid input: expected email, received object'])); expect(body).toEqual(
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 () => {
@@ -195,19 +211,31 @@ 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(errorDto.badRequest([String.raw`[pinCode] Invalid string: must match pattern /^\d{6}$/`])); expect(body).toEqual(
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(errorDto.badRequest([String.raw`[pinCode] Invalid string: must match pattern /^\d{6}$/`])); expect(body).toEqual(
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(errorDto.badRequest([String.raw`[pinCode] Invalid string: must match pattern /^\d{6}$/`])); expect(body).toEqual(
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.badRequest(['[id] Invalid UUID'])); expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: '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: 'Delete a duplicate', summary: 'Dismiss a duplicate group',
description: 'Delete a single duplicate asset specified by its ID.', description: 'Dismiss a duplicate group by its ID, unlinking all assets in the group without deleting them.',
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,7 +31,9 @@ describe(MaintenanceController.name, () => {
}); });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(
errorDto.badRequest(['[restoreBackupFilename] Backup filename is required when action is restore_database']), errorDto.validationError([
{ path: ['restoreBackupFilename'], message: 'Backup filename is required when action is restore_database' },
]),
); );
expect(ctx.authenticate).toHaveBeenCalled(); expect(ctx.authenticate).toHaveBeenCalled();
}); });
@@ -47,7 +47,11 @@ describe(MemoryController.name, () => {
}); });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[data.year] Invalid input: expected number, received undefined'])); expect(body).toEqual(
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 () => {
@@ -81,7 +85,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.badRequest(['[id] Invalid UUID'])); expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
}); });
}); });
@@ -94,13 +98,15 @@ 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(errorDto.badRequest(['Invalid input: expected object, received undefined'])); expect(body).toEqual(
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.badRequest(['At least one field must be provided'])); expect(body).toEqual(errorDto.validationError([{ path: [], message: 'At least one field must be provided' }]));
}); });
}); });
@@ -120,7 +126,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.badRequest(['[id] Invalid UUID'])); expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
}); });
it('should require a valid asset id', async () => { it('should require a valid asset id', async () => {
@@ -128,7 +134,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.badRequest(['[ids.0] Invalid UUID'])); expect(body).toEqual(errorDto.validationError([{ path: ['ids', 0], message: 'Invalid UUID' }]));
}); });
}); });
@@ -141,7 +147,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.badRequest(['[id] Invalid UUID'])); expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
}); });
it('should require a valid asset id', async () => { it('should require a valid asset id', async () => {
@@ -149,7 +155,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.badRequest(['[ids.0] Invalid UUID'])); expect(body).toEqual(errorDto.validationError([{ path: ['ids', 0], message: 'Invalid UUID' }]));
}); });
}); });
}); });
@@ -31,7 +31,11 @@ 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(errorDto.badRequest([expect.stringContaining('[level] Invalid option: expected one of')])); expect(body).toEqual(
errorDto.validationError([
{ path: ['level'], message: expect.stringContaining('Invalid option: expected one of') },
]),
);
}); });
}); });
@@ -45,7 +49,9 @@ 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(errorDto.badRequest(['[ids] Invalid input: expected array, received boolean'])); expect(body).toEqual(
errorDto.validationError([{ path: ['ids'], message: 'Invalid input: expected array, received boolean' }]),
);
}); });
it('should require uuids', async () => { it('should require uuids', async () => {
@@ -53,7 +59,9 @@ describe(NotificationController.name, () => {
.put(`/notifications`) .put(`/notifications`)
.send({ ids: [true] }); .send({ ids: [true] });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[ids.0] Invalid input: expected string, received boolean'])); expect(body).toEqual(
errorDto.validationError([{ path: ['ids', 0], message: 'Invalid input: expected string, received boolean' }]),
);
}); });
it('should accept valid uuids', async () => { it('should accept valid uuids', async () => {
@@ -75,7 +83,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.badRequest(['[id] Invalid UUID'])); expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
}); });
}); });
@@ -33,7 +33,9 @@ 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.badRequest([expect.stringContaining('[direction] Invalid option: expected one of')]), errorDto.validationError([
{ path: ['direction'], message: expect.stringContaining('Invalid option: expected one of') },
]),
); );
}); });
@@ -44,7 +46,9 @@ 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.badRequest([expect.stringContaining('[direction] Invalid option: expected one of')]), errorDto.validationError([
{ path: ['direction'], message: expect.stringContaining('Invalid option: expected one of') },
]),
); );
}); });
}); });
@@ -61,7 +65,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.badRequest(['[sharedWithId] Invalid UUID'])); expect(body).toEqual(errorDto.validationError([{ path: ['sharedWithId'], message: 'Invalid UUID' }]));
}); });
}); });
@@ -77,7 +81,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.badRequest(['[id] Invalid UUID'])); expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
}); });
}); });
@@ -92,7 +96,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.badRequest(['[id] Invalid UUID'])); expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: '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.badRequest(['[closestPersonId] Invalid UUID'])); expect(body).toEqual(errorDto.validationError([{ path: ['closestPersonId'], message: '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.badRequest(['[closestAssetId] Invalid UUID'])); expect(body).toEqual(errorDto.validationError([{ path: ['closestAssetId'], message: '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.badRequest(['[ids.0] Invalid UUID'])); expect(body).toEqual(errorDto.validationError([{ path: ['ids', 0], message: 'Invalid UUID' }]));
}); });
it('should respond with 204', async () => { it('should respond with 204', async () => {
@@ -104,7 +104,9 @@ 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(errorDto.badRequest(['Invalid input: expected object, received undefined'])); expect(body).toEqual(
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 () => {
@@ -113,7 +115,9 @@ 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(errorDto.badRequest(['[name] Invalid input: expected string, received null'])); expect(body).toEqual(
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 () => {
@@ -122,7 +126,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.badRequest(['[featureFaceAssetId] Invalid UUID'])); expect(body).toEqual(errorDto.validationError([{ path: ['featureFaceAssetId'], message: 'Invalid UUID' }]));
}); });
it(`should require isFavorite to be a boolean`, async () => { it(`should require isFavorite to be a boolean`, async () => {
@@ -131,7 +135,11 @@ 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(errorDto.badRequest(['[isFavorite] Invalid input: expected boolean, received string'])); expect(body).toEqual(
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 () => {
@@ -140,7 +148,9 @@ 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(errorDto.badRequest(['[isHidden] Invalid input: expected boolean, received string'])); expect(body).toEqual(
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 () => {
@@ -154,7 +164,11 @@ 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(errorDto.badRequest(['[birthDate] Invalid input: expected string, received boolean'])); expect(body).toEqual(
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 () => {
@@ -162,7 +176,9 @@ 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(errorDto.badRequest(['[birthDate] Invalid input: expected string, received number'])); expect(body).toEqual(
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 () => {
@@ -170,7 +186,9 @@ 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(errorDto.badRequest(['[birthDate] Birth date cannot be in the future'])); expect(body).toEqual(
errorDto.validationError([{ path: ['birthDate'], message: 'Birth date cannot be in the future' }]),
);
}); });
}); });
@@ -183,7 +201,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.badRequest(['[id] Invalid UUID'])); expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
}); });
it('should respond with 204', async () => { it('should respond with 204', async () => {
@@ -27,31 +27,41 @@ 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(errorDto.badRequest(['[page] Invalid input: expected number, received string'])); expect(body).toEqual(
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(errorDto.badRequest(['[page] Too small: expected number to be >=1'])); expect(body).toEqual(
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(errorDto.badRequest(['[page] Too small: expected number to be >=1'])); expect(body).toEqual(
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(errorDto.badRequest(['[size] Invalid input: expected number, received string'])); expect(body).toEqual(
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(errorDto.badRequest(['[size] Too small: expected number to be >=1'])); expect(body).toEqual(
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 () => {
@@ -60,7 +70,9 @@ describe(SearchController.name, () => {
.send({ visibility: 'immich' }); .send({ visibility: 'immich' });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(
errorDto.badRequest([expect.stringContaining('[visibility] Invalid option: expected one of')]), errorDto.validationError([
{ path: ['visibility'], message: expect.stringContaining('Invalid option: expected one of') },
]),
); );
}); });
@@ -69,7 +81,11 @@ 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(errorDto.badRequest(['[isFavorite] Invalid input: expected boolean, received string'])); expect(body).toEqual(
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 () => {
@@ -77,7 +93,11 @@ 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(errorDto.badRequest(['[isEncoded] Invalid input: expected boolean, received string'])); expect(body).toEqual(
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 () => {
@@ -85,13 +105,19 @@ 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(errorDto.badRequest(['[isOffline] Invalid input: expected boolean, received string'])); expect(body).toEqual(
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(errorDto.badRequest(['[isMotion] Invalid input: expected boolean, received string'])); expect(body).toEqual(
errorDto.validationError([{ path: ['isMotion'], message: 'Invalid input: expected boolean, received string' }]),
);
}); });
describe('POST /search/random', () => { describe('POST /search/random', () => {
@@ -105,7 +131,11 @@ 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(errorDto.badRequest(['[withStacked] Invalid input: expected boolean, received string'])); expect(body).toEqual(
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 () => {
@@ -113,7 +143,11 @@ 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(errorDto.badRequest(['[withPeople] Invalid input: expected boolean, received string'])); expect(body).toEqual(
errorDto.validationError([
{ path: ['withPeople'], message: 'Invalid input: expected boolean, received string' },
]),
);
}); });
}); });
@@ -140,7 +174,9 @@ 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(errorDto.badRequest(['[name] Invalid input: expected string, received undefined'])); expect(body).toEqual(
errorDto.validationError([{ path: ['name'], message: 'Invalid input: expected string, received undefined' }]),
);
}); });
}); });
@@ -153,7 +189,9 @@ 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(errorDto.badRequest(['[name] Invalid input: expected string, received undefined'])); expect(body).toEqual(
errorDto.validationError([{ path: ['name'], message: 'Invalid input: expected string, received undefined' }]),
);
}); });
}); });
@@ -173,7 +211,11 @@ 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(errorDto.badRequest([expect.stringContaining('[type] Invalid option: expected one of')])); expect(body).toEqual(
errorDto.validationError([
{ path: ['type'], message: expect.stringContaining('Invalid option: expected one of') },
]),
);
}); });
}); });
}); });
+13 -3
View File
@@ -35,7 +35,11 @@ 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(errorDto.badRequest([expect.stringContaining('[types.0] Invalid option: expected one of')])); expect(body).toEqual(
errorDto.validationError([
{ path: ['types', 0], message: expect.stringContaining('Invalid option: expected one of') },
]),
);
expect(ctx.authenticate).toHaveBeenCalled(); expect(ctx.authenticate).toHaveBeenCalled();
}); });
}); });
@@ -57,7 +61,9 @@ 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(errorDto.badRequest(['[acks] Too big: expected array to have <=1000 items'])); expect(body).toEqual(
errorDto.validationError([{ path: ['acks'], message: 'Too big: expected array to have <=1000 items' }]),
);
expect(ctx.authenticate).toHaveBeenCalled(); expect(ctx.authenticate).toHaveBeenCalled();
}); });
}); });
@@ -73,7 +79,11 @@ 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(errorDto.badRequest([expect.stringContaining('[types.0] Invalid option: expected one of')])); expect(body).toEqual(
errorDto.validationError([
{ path: ['types', 0], message: expect.stringContaining('Invalid option: expected one of') },
]),
);
expect(ctx.authenticate).toHaveBeenCalled(); expect(ctx.authenticate).toHaveBeenCalled();
}); });
}); });
@@ -67,8 +67,11 @@ 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.badRequest([ errorDto.validationError([
'[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',
},
]), ]),
); );
}); });
@@ -86,7 +89,9 @@ 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.badRequest(['[nightlyTasks.databaseCleanup] Invalid input: expected boolean, received string']), errorDto.validationError([
{ path: ['nightlyTasks', 'databaseCleanup'], message: 'Invalid input: expected boolean, received string' },
]),
); );
}); });
}); });
@@ -116,7 +121,12 @@ 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.badRequest(['[image.thumbnail.progressive] Invalid input: expected boolean, received string']), errorDto.validationError([
{
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.badRequest(['[id] Invalid UUID'])); expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
}); });
}); });
@@ -42,7 +42,9 @@ 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.badRequest(['[bbox] bbox must have 4 comma-separated numbers: west,south,east,north'] as any), errorDto.validationError([
{ path: ['bbox'], message: 'bbox must have 4 comma-separated numbers: west,south,east,north' },
]),
); );
}); });
@@ -51,7 +53,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.badRequest(['[bbox] bbox parts must be valid numbers'] as any)); expect(body).toEqual(errorDto.validationError([{ path: ['bbox'], message: 'bbox parts must be valid numbers' }]));
}); });
}); });
@@ -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.badRequest( errorDto.validationError([
expect.arrayContaining(['[quotaSizeInBytes] Invalid input: expected int, received number']), { path: ['quotaSizeInBytes'], message: '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.badRequest( errorDto.validationError([
expect.arrayContaining(['[quotaSizeInBytes] Invalid input: expected int, received number']), { path: ['quotaSizeInBytes'], message: '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.badRequest( errorDto.validationError([
expect.arrayContaining(['[quotaSizeInBytes] Invalid input: expected int, received number']), { path: ['quotaSizeInBytes'], message: 'Invalid input: expected int, received number' },
), ]),
); );
}); });
@@ -43,15 +43,17 @@ describe(UserController.name, () => {
expect(ctx.authenticate).toHaveBeenCalled(); expect(ctx.authenticate).toHaveBeenCalled();
}); });
for (const key of ['email', 'name']) { for (const [key, message] of [
['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(dto); .send({ [key]: null });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest()); expect(body).toEqual(errorDto.validationError([{ path: [key], message }]));
}); });
} }
-2
View File
@@ -3,7 +3,6 @@ import { MapAsset } from 'src/dtos/asset-response.dto';
import { import {
AlbumUserRole, AlbumUserRole,
AssetFileType, AssetFileType,
AssetOrder,
AssetType, AssetType,
AssetVisibility, AssetVisibility,
ChecksumAlgorithm, ChecksumAlgorithm,
@@ -196,7 +195,6 @@ 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) {
body['message'] = zodError.issues.map((issue) => return {
issue.path.length > 0 ? `[${issue.path.join('.')}] ${issue.message}` : issue.message, status,
); body: { message: 'Validation failed', errors: zodError.issues },
};
} }
} }
// remove fields that duplicate the HTTP response line or will be reformatted in a later step // remove fields injected by NestJS that duplicate the HTTP response line
delete body['error']; delete body['error'];
delete body['statusCode']; delete body['statusCode'];
delete body['errors'];
return { status, body }; return { status, body };
} }
+6 -45
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, AssetOrder } from 'src/enum'; import { AlbumUserRole } 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,23 +82,6 @@ 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>) {}
@@ -112,7 +95,6 @@ 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 }>()
@@ -136,7 +118,6 @@ 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();
} }
@@ -214,7 +195,6 @@ 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')
@@ -259,7 +239,6 @@ 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();
@@ -292,7 +271,6 @@ 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')
@@ -372,13 +350,13 @@ export class AlbumRepository {
params: [ params: [
{ albumName: DummyValue.STRING }, { albumName: DummyValue.STRING },
[], [],
[{ userId: DummyValue.UUID, role: AlbumUserRole.Owner, order: AssetOrder.Desc }, DummyValue.UUID], [{ userId: DummyValue.UUID, role: AlbumUserRole.Owner }, DummyValue.UUID],
], ],
}) })
async create( async create(
album: Insertable<AlbumTable>, album: Insertable<AlbumTable>,
assetIds: string[], assetIds: string[],
albumUsers: (AlbumUserCreateDto & { order?: AssetOrder })[], albumUsers: AlbumUserCreateDto[],
authUserId: string, authUserId: string,
) { ) {
if (!albumUsers.some((u) => u.role === AlbumUserRole.Owner)) { if (!albumUsers.some((u) => u.role === AlbumUserRole.Owner)) {
@@ -387,14 +365,12 @@ 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')
@@ -402,15 +378,13 @@ 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', 'album_user.order']), .returning(['album_user.albumId', 'album_user.userId', 'album_user.role']),
) )
.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')
@@ -422,7 +396,6 @@ 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();
@@ -430,27 +403,15 @@ export class AlbumRepository {
return result; return result;
} }
update(id: string, album: Updateable<AlbumTable>, authUserId: string, order?: AssetOrder) { update(id: string, album: Updateable<AlbumTable>, authUserId: string) {
return this.db.transaction().execute(async (db) => { return this.db
if (order !== undefined) {
await db
.updateTable('album_user')
.set({ order })
.where('albumId', '=', id)
.where('userId', '=', authUserId)
.execute();
}
return db
.updateTable('album') .updateTable('album')
.set(album) .set(album)
.where('album.id', '=', id) .where('album.id', '=', id)
.returningAll('album') .returningAll('album')
.returning(withSharedLink) .returning(withSharedLink)
.returning(withAlbumUsers(authUserId)) .returning(withAlbumUsers(authUserId))
.returning(withOrder(authUserId))
.executeTakeFirstOrThrow(); .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, AssetOrder, SharedLinkType } from 'src/enum'; import { AlbumUserRole, 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,15 +55,7 @@ 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);
}; };
@@ -115,7 +107,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', 'album_order.order', sql`"owner".*`]) .groupBy(['album.id', 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'])
.innerJoin('album_user as album_users', 'album.id', 'album_users.albumId') .leftJoin('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_users.order as order', 'album.order',
'album.updateId', 'album.updateId',
]) ])
.stream(); .stream();
@@ -1,21 +0,0 @@
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);
}
+1 -4
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, AssetOrder } from 'src/enum'; import { AlbumUserRole } 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,9 +58,6 @@ 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,6 +10,7 @@ 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' })
@@ -44,6 +45,9 @@ 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>;
} }
+8 -27
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, order: AssetOrder.Desc }, { userId: owner.id, role: AlbumUserRole.Owner },
{ userId: albumUser.userId, role: AlbumUserRole.Editor, order: AssetOrder.Desc }, { userId: albumUser.userId, role: AlbumUserRole.Editor },
], ],
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,19 +238,16 @@ 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,
@@ -293,10 +290,11 @@ 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, order: 'desc' }], [{ userId: owner.id, role: AlbumUserRole.Owner }],
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);
@@ -366,24 +364,10 @@ 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,
expect.objectContaining({ id: album.id, albumName: 'new album name' }), { 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', () => {
@@ -480,7 +464,6 @@ 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 }] });
@@ -488,9 +471,7 @@ 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,
+4 -14
View File
@@ -124,22 +124,16 @@ 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, order: ownerOrder }, ...albumUsersWithOrder], [{ userId: auth.user.id, role: AlbumUserRole.Owner }, ...albumUsers],
auth.user.id, auth.user.id,
); );
@@ -161,7 +155,6 @@ 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,
{ {
@@ -170,9 +163,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 });
@@ -314,10 +307,7 @@ export class AlbumService extends BaseService {
throw new BadRequestException('Invalid user'); throw new BadRequestException('Invalid user');
} }
const userMetadata = await this.userRepository.getMetadata(userId); await this.albumUserRepository.create({ userId, albumId: id, role });
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,3 +1,4 @@
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';
@@ -149,6 +150,36 @@ 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,10 +82,12 @@ 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);
} }
+1 -2
View File
@@ -1,5 +1,5 @@
import { Selectable } from 'kysely'; import { Selectable } from 'kysely';
import { AlbumUserRole, AssetOrder } from 'src/enum'; import { AlbumUserRole } 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,7 +24,6 @@ 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> & { order: AssetOrder }) {} private constructor(private readonly value: Selectable<AlbumTable>) {}
static create(dto: AlbumLike = {}) { static create(dto: AlbumLike = {}) {
return AlbumFactory.from(dto).build(); return AlbumFactory.from(dto).build();
+1 -2
View File
@@ -1,5 +1,4 @@
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';
@@ -24,7 +23,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> & { order: AssetOrder }>; export type AlbumLike = Partial<Selectable<AlbumTable>>;
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,6 +25,10 @@ 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,6 +3,7 @@ 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 = () =>
@@ -248,5 +249,9 @@ 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,7 +284,11 @@
{/snippet} {/snippet}
</AdaptiveImage> </AdaptiveImage>
{#if assetViewerManager.isFaceEditMode && assetViewerManager.imgRef} {#if assetViewerManager.isFaceEditMode && assetViewerManager.imgRef && asset.width && asset.height}
<FaceEditor htmlElement={assetViewerManager.imgRef} {containerWidth} {containerHeight} assetId={asset.id} /> <FaceEditor
assetSize={{ width: asset.width, height: asset.height }}
containerSize={{ width: containerWidth, height: containerHeight }}
assetId={asset.id}
/>
{/if} {/if}
</div> </div>
@@ -140,9 +140,40 @@
let containerHeight = $state(0); let containerHeight = $state(0);
$effect(() => { $effect(() => {
if (assetViewerManager.isFaceEditMode) { if (!assetViewerManager.isFaceEditMode || !videoPlayer) {
videoPlayer?.pause(); return;
} }
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>
@@ -248,7 +279,11 @@
{/if} {/if}
{#if assetViewerManager.isFaceEditMode} {#if assetViewerManager.isFaceEditMode}
<FaceEditor htmlElement={videoPlayer} {containerWidth} {containerHeight} {assetId} /> <FaceEditor
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 { getNaturalSize, scaleToFit } from '$lib/utils/container-utils'; import { computeContentMetrics, mapContentRectToNatural, type Size } 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,13 +14,12 @@
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
type Props = { type Props = {
htmlElement: HTMLImageElement | HTMLVideoElement; assetSize: Size;
containerWidth: number; containerSize: Size;
containerHeight: number;
assetId: string; assetId: string;
}; };
let { htmlElement, containerWidth, containerHeight, assetId }: Props = $props(); let { assetSize, containerSize, assetId }: Props = $props();
let canvasEl: HTMLCanvasElement | undefined = $state(); let canvasEl: HTMLCanvasElement | undefined = $state();
let canvas: Canvas | undefined = $state(); let canvas: Canvas | undefined = $state();
@@ -54,7 +53,7 @@
}; };
const setupCanvas = () => { const setupCanvas = () => {
if (!canvasEl || !htmlElement) { if (!canvasEl) {
return; return;
} }
@@ -86,24 +85,14 @@
searchInputEl?.focus(); searchInputEl?.focus();
}); });
const imageContentMetrics = $derived.by(() => { const imageContentMetrics = $derived(computeContentMetrics(assetSize, containerSize));
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 } = imageContentMetrics; const { offsetX, offsetY, contentWidth, contentHeight } = imageContentMetrics;
faceRect.set({ faceRect.set({
top: offsetY + 200, top: offsetY + contentHeight / 2 - 56,
left: offsetX + 200, left: offsetX + contentWidth / 2 - 56,
}); });
faceRect.setCoords(); faceRect.setCoords();
@@ -116,8 +105,8 @@
} }
canvas.setDimensions({ canvas.setDimensions({
width: containerWidth, width: containerSize.width,
height: containerHeight, height: containerSize.height,
}); });
if (!faceRect) { if (!faceRect) {
@@ -167,6 +156,9 @@
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,
@@ -175,11 +167,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, containerHeight - gap * 2 - chromeHeight); const listHeight = Math.min(MAX_LIST_HEIGHT, containerSize.height - gap * 2 - chromeHeight);
const selectorHeight = listHeight + chromeHeight; const selectorHeight = listHeight + chromeHeight;
const clampTop = (top: number) => clamp(top, gap, containerHeight - selectorHeight - gap); const clampTop = (top: number) => clamp(top, gap, containerSize.height - selectorHeight - gap);
const clampLeft = (left: number) => clamp(left, gap, containerWidth - selectorWidth - gap); const clampLeft = (left: number) => clamp(left, gap, containerSize.width - 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;
@@ -238,45 +230,37 @@
}); });
const getFaceCroppedCoordinates = () => { const getFaceCroppedCoordinates = () => {
if (!faceRect || !htmlElement) { if (!faceRect || imageContentMetrics.contentWidth === 0) {
return; return;
} }
const { left, top, width, height } = faceRect.getBoundingRect(); const imageRect = mapContentRectToNatural(faceRect.getBoundingRect(), imageContentMetrics, assetSize);
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: natural.width, imageWidth: assetSize.width,
imageHeight: natural.height, imageHeight: assetSize.height,
x: Math.floor(imageX), x: Math.floor(imageRect.left),
y: Math.floor(imageY), y: Math.floor(imageRect.top),
width: Math.floor(width * scaleX), width: Math.floor(imageRect.width),
height: Math.floor(height * scaleY), height: Math.floor(imageRect.height),
}; };
}; };
type FaceCoordinates = NonNullable<ReturnType<typeof getFaceCroppedCoordinates>>; type FaceCoordinates = NonNullable<ReturnType<typeof getFaceCroppedCoordinates>>;
const getFacePreviewUrl = (data: FaceCoordinates) => { const getFacePreviewUrl = (data: FaceCoordinates) => {
if (!htmlElement) { const imgRef = assetViewerManager.imgRef;
if (!imgRef || imageContentMetrics.contentWidth === 0) {
return; return;
} }
const natural = getNaturalSize(htmlElement); const scaleX = imgRef.naturalWidth / assetSize.width;
if (natural.width <= 0 || natural.height <= 0) { const scaleY = imgRef.naturalHeight / assetSize.height;
return;
}
const x = clamp(data.x, 0, natural.width - 1); const x = clamp(Math.floor(data.x * scaleX), 0, imgRef.naturalWidth - 1);
const y = clamp(data.y, 0, natural.height - 1); const y = clamp(Math.floor(data.y * scaleY), 0, imgRef.naturalHeight - 1);
const width = clamp(data.width, 1, natural.width - x); const width = clamp(Math.floor(data.width * scaleX), 1, imgRef.naturalWidth - x);
const height = clamp(data.height, 1, natural.height - y); const height = clamp(Math.floor(data.height * scaleY), 1, imgRef.naturalHeight - y);
if (width <= 0 || height <= 0) { if (width <= 0 || height <= 0) {
return; return;
@@ -292,7 +276,7 @@
} }
try { try {
context.drawImage(htmlElement, x, y, width, height, 0, 0, width, height); context.drawImage(imgRef, x, y, width, height, 0, 0, width, height);
return canvas.toDataURL('image/png'); return canvas.toDataURL('image/png');
} catch { } catch {
return; return;
+67 -33
View File
@@ -1,18 +1,15 @@
import { import {
getContentMetrics, computeContentMetrics,
getNaturalSize, getNaturalSize,
mapContentRectToNatural,
mapNormalizedRectToContent, mapNormalizedRectToContent,
mapNormalizedToContent, mapNormalizedToContent,
scaleToCover, scaleToCover,
scaleToFit, scaleToFit,
} from '$lib/utils/container-utils'; } from '$lib/utils/container-utils';
const mockImage = (props: { const mockImage = (props: { naturalWidth: number; naturalHeight: number }): HTMLImageElement =>
naturalWidth: number; props as unknown as HTMLImageElement;
naturalHeight: number;
width: number;
height: number;
}): HTMLImageElement => props as unknown as HTMLImageElement;
const mockVideo = (props: { const mockVideo = (props: {
videoWidth: number; videoWidth: number;
@@ -49,48 +46,85 @@ describe('scaleToFit', () => {
}); });
}); });
describe('getContentMetrics', () => { describe('computeContentMetrics', () => {
it('should compute zero offsets when aspect ratios match', () => { it('should return zero metrics for zero-width content', () => {
const img = mockImage({ naturalWidth: 1600, naturalHeight: 900, width: 800, height: 450 }); expect(computeContentMetrics({ width: 0, height: 1080 }, { width: 800, height: 600 })).toEqual({
expect(getContentMetrics(img)).toEqual({ contentWidth: 0,
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,
}); });
}); });
});
it('should compute horizontal letterbox offsets for tall image', () => { describe('mapContentRectToNatural', () => {
const img = mockImage({ naturalWidth: 1000, naturalHeight: 2000, width: 800, height: 600 }); it('should map a full-content rect back to natural size', () => {
const metrics = getContentMetrics(img); const metrics = { contentWidth: 800, contentHeight: 400, offsetX: 0, offsetY: 100 };
expect(metrics.contentWidth).toBe(300); const rect = mapContentRectToNatural({ left: 0, top: 100, width: 800, height: 400 }, metrics, {
expect(metrics.contentHeight).toBe(600); width: 2000,
expect(metrics.offsetX).toBe(250); height: 1000,
expect(metrics.offsetY).toBe(0); });
expect(rect).toEqual({ left: 0, top: 0, width: 2000, height: 1000 });
}); });
it('should compute vertical letterbox offsets for wide image', () => { it('should map a centered sub-rect to natural coordinates', () => {
const img = mockImage({ naturalWidth: 2000, naturalHeight: 1000, width: 800, height: 600 }); const metrics = { contentWidth: 800, contentHeight: 400, offsetX: 0, offsetY: 100 };
const metrics = getContentMetrics(img); const rect = mapContentRectToNatural({ left: 200, top: 200, width: 400, height: 200 }, metrics, {
expect(metrics.contentWidth).toBe(800); width: 2000,
expect(metrics.contentHeight).toBe(400); height: 1000,
expect(metrics.offsetX).toBe(0); });
expect(metrics.offsetY).toBe(100); expect(rect).toEqual({ left: 500, top: 250, width: 1000, height: 500 });
}); });
it('should use clientWidth/clientHeight for video elements', () => { it('should handle letterboxed content with horizontal offset', () => {
const video = mockVideo({ videoWidth: 1920, videoHeight: 1080, clientWidth: 800, clientHeight: 600 }); const metrics = { contentWidth: 300, contentHeight: 600, offsetX: 250, offsetY: 0 };
const metrics = getContentMetrics(video); const rect = mapContentRectToNatural({ left: 250, top: 0, width: 300, height: 600 }, metrics, {
expect(metrics.contentWidth).toBe(800); width: 1000,
expect(metrics.contentHeight).toBe(450); height: 2000,
expect(metrics.offsetX).toBe(0); });
expect(metrics.offsetY).toBe(75); expect(rect).toEqual({ left: 0, top: 0, width: 1000, height: 2000 });
}); });
}); });
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, width: 800, height: 600 }); const img = mockImage({ naturalWidth: 4000, naturalHeight: 3000 });
expect(getNaturalSize(img)).toEqual({ width: 4000, height: 3000 }); expect(getNaturalSize(img)).toEqual({ width: 4000, height: 3000 });
}); });
+30 -14
View File
@@ -49,13 +49,6 @@ 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 };
@@ -63,17 +56,18 @@ export const getNaturalSize = (element: HTMLImageElement | HTMLVideoElement): Si
return { width: element.naturalWidth, height: element.naturalHeight }; return { width: element.naturalWidth, height: element.naturalHeight };
}; };
export const getContentMetrics = (element: HTMLImageElement | HTMLVideoElement): ContentMetrics => { export function computeContentMetrics(content: Size, container: Size): ContentMetrics {
const natural = getNaturalSize(element); if (content.width === 0 || content.height === 0) {
const client = getElementSize(element); return { contentWidth: 0, contentHeight: 0, offsetX: 0, offsetY: 0 };
const { width: contentWidth, height: contentHeight } = scaleToFit(natural, client); }
const { width: contentWidth, height: contentHeight } = scaleToFit(content, container);
return { return {
contentWidth, contentWidth,
contentHeight, contentHeight,
offsetX: (client.width - contentWidth) / 2, offsetX: (container.width - contentWidth) / 2,
offsetY: (client.height - contentHeight) / 2, offsetY: (container.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) {
@@ -109,3 +103,25 @@ 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,6 +16,18 @@ 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,6 +7,9 @@ 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'));
}); });
@@ -31,6 +34,13 @@ 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) }); return date.toRelativeCalendar({ locale: get(locale), unit: 'days' });
} }
// Last week // Last week