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) => ({
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: {
message: expect.stringContaining('Not found or no'),
},
@@ -37,9 +41,6 @@ export const errorDto = {
alreadyHasAdmin: {
message: 'The server already has an admin',
},
invalidEmail: {
message: ['email must be an email'],
},
};
export const signupResponseDto = {
+21 -7
View File
@@ -110,7 +110,9 @@ describe('/libraries', () => {
});
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 () => {
@@ -125,7 +127,9 @@ describe('/libraries', () => {
});
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: '' });
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 () => {
@@ -181,7 +187,9 @@ describe('/libraries', () => {
.send({ importPaths: [''] });
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 () => {
@@ -191,7 +199,9 @@ describe('/libraries', () => {
.send({ importPaths: ['/path', '/path'] });
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 () => {
@@ -215,7 +225,9 @@ describe('/libraries', () => {
.send({ exclusionPatterns: ['**/*.jpg', '**/*.jpg'] });
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 () => {
@@ -225,7 +237,9 @@ describe('/libraries', () => {
.send({ exclusionPatterns: [''] });
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')
.set('Authorization', `Bearer ${admin.accessToken}`);
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 () => {
@@ -117,7 +119,9 @@ describe('/map', () => {
.get('/map/reverse-geocode?lat=abc&lon=123.456')
.set('Authorization', `Bearer ${admin.accessToken}`);
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 () => {
@@ -125,7 +129,9 @@ describe('/map', () => {
.get('/map/reverse-geocode?lat=91&lon=123.456')
.set('Authorization', `Bearer ${admin.accessToken}`);
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 () => {
@@ -133,7 +139,9 @@ describe('/map', () => {
.get('/map/reverse-geocode?lat=75')
.set('Authorization', `Bearer ${admin.accessToken}`);
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 = [
+16 -4
View File
@@ -105,7 +105,11 @@ describe(`/oauth`, () => {
it(`should throw an error if a redirect uri is not provided`, async () => {
const { status, body } = await request(app).post('/oauth/authorize').send({});
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 () => {
@@ -164,13 +168,17 @@ describe(`/oauth`, () => {
it(`should throw an error if a url is not provided`, async () => {
const { status, body } = await request(app).post('/oauth/callback').send({});
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 () => {
const { status, body } = await request(app).post('/oauth/callback').send({ url: '' });
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 () => {
@@ -375,7 +383,11 @@ describe(`/oauth`, () => {
it(`should throw an error if the logout_token is not provided`, async () => {
const { status, body } = await request(app).post('/oauth/backchannel-logout').send({});
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 () => {
@@ -341,7 +341,9 @@ describe('/shared-links', () => {
.set('Authorization', `Bearer ${user1.accessToken}`);
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 () => {
+9 -2
View File
@@ -41,7 +41,9 @@ describe('/stacks', () => {
.send({ assetIds: [asset.id] });
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 () => {
@@ -51,7 +53,12 @@ describe('/stacks', () => {
.send({ assetIds: [uuidDto.invalid, uuidDto.invalid] });
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 () => {
+2 -2
View File
@@ -309,7 +309,7 @@ describe('/tags', () => {
.get(`/tags/${uuidDto.invalid}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
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 () => {
@@ -427,7 +427,7 @@ describe('/tags', () => {
.delete(`/tags/${uuidDto.invalid}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
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 () => {
@@ -108,14 +108,20 @@ describe('/admin/users', () => {
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 () => {
const { status, body } = await request(app)
.post(`/admin/users`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ ...createUserDto.user1, [key]: null });
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);
});
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 () => {
const { status, body } = await request(app)
.put(`/admin/users/${uuidDto.notFound}`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ [key]: null });
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(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(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 && \
# 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 && \
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 \
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
+1 -1
View File
@@ -146,7 +146,7 @@ Class | Method | HTTP request | Description
*DeprecatedApi* | [**runQueueCommandLegacy**](doc//DeprecatedApi.md#runqueuecommandlegacy) | **PUT** /jobs/{name} | Run jobs
*DownloadApi* | [**downloadArchive**](doc//DownloadApi.md#downloadarchive) | **POST** /download/archive | Download asset archive
*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* | [**getAssetDuplicates**](doc//DuplicatesApi.md#getassetduplicates) | **GET** /duplicates | Retrieve duplicates
*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;
/// 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].
///
@@ -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:
///
+2 -2
View File
@@ -5172,7 +5172,7 @@
},
"/duplicates/{id}": {
"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",
"parameters": [
{
@@ -5202,7 +5202,7 @@
"api_key": []
}
],
"summary": "Delete a duplicate",
"summary": "Dismiss a duplicate group",
"tags": [
"Duplicates"
],
+1 -1
View File
@@ -4480,7 +4480,7 @@ export function resolveDuplicates({ duplicateResolveDto }: {
})));
}
/**
* Delete a duplicate
* Dismiss a duplicate group
*/
export function deleteDuplicate({ id }: {
id: string;
@@ -1,9 +1,16 @@
import { HttpError } from '@oazapfts/runtime';
export interface ApiValidationError {
code: string;
path: (string | number)[];
message: string;
}
export interface ApiExceptionResponse {
message: string;
error?: string;
statusCode: number;
errors?: ApiValidationError[];
}
export interface ApiHttpError extends HttpError {
@@ -28,14 +28,16 @@ describe(ActivityController.name, () => {
const { status, body } = await request(ctx.getHttpServer()).get('/activities');
expect(status).toEqual(400);
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 () => {
const { status, body } = await request(ctx.getHttpServer()).get('/activities').query({ albumId: '123' });
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 () => {
@@ -43,7 +45,7 @@ describe(ActivityController.name, () => {
.get('/activities')
.query({ albumId: factory.uuid(), assetId: '123' });
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')
.send({ albumId: '123', type: 'like' });
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 () => {
@@ -66,7 +68,11 @@ describe(ActivityController.name, () => {
.post('/activities')
.send({ albumId: factory.uuid(), type: 'comment', comment: null });
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 () => {
const { status, body } = await request(ctx.getHttpServer()).delete(`/activities/123`);
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 () => {
const { status, body } = await request(ctx.getHttpServer()).get('/albums?shared=invalid');
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 () => {
const { status, body } = await request(ctx.getHttpServer()).get('/albums?assetId=invalid');
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 () => {
const { status, body } = await request(ctx.getHttpServer()).get(`/api-keys/123`);
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`)
.send({ name: 'new name', permissions: [Permission.All] });
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 () => {
@@ -84,7 +84,7 @@ describe(ApiKeyController.name, () => {
it('should require a valid uuid', async () => {
const { status, body } = await request(ctx.getHttpServer()).delete(`/api-keys/123`);
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(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' }) });
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.badRequest([
'[fileCreatedAt] Invalid input: expected ISO 8601 datetime string, received undefined',
factory.responses.validationError([
{ path: ['fileCreatedAt'], message: 'Invalid input: expected ISO 8601 datetime string, received undefined' },
]),
);
});
@@ -104,8 +106,8 @@ describe(AssetMediaController.name, () => {
.field(makeUploadDto({ omit: 'fileModifiedAt' }));
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.badRequest([
'[fileModifiedAt] Invalid input: expected ISO 8601 datetime string, received undefined',
factory.responses.validationError([
{ 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' });
expect(status).toBe(400);
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' });
expect(status).toBe(400);
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') },
]),
);
});
+87 -51
View File
@@ -31,7 +31,7 @@ describe(AssetController.name, () => {
.send({ ids: ['123'] });
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 () => {
@@ -42,7 +42,9 @@ describe(AssetController.name, () => {
expect(status).toBe(400);
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'] });
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 () => {
const { status, body } = await request(ctx.getHttpServer()).get(`/assets/123`);
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({});
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.badRequest(
expect.arrayContaining([
'[sourceId] Invalid input: expected string, received undefined',
'[targetId] Invalid input: expected string, received undefined',
]),
),
factory.responses.validationError([
{ path: ['sourceId'], message: 'Invalid input: expected string, received undefined' },
{ path: ['targetId'], message: 'Invalid input: expected string, received undefined' },
]),
);
});
@@ -125,7 +125,9 @@ describe(AssetController.name, () => {
.put('/assets/metadata')
.send({ items: [{ assetId: '123', key: 'test', value: {} }] });
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 () => {
@@ -134,9 +136,9 @@ describe(AssetController.name, () => {
.send({ items: [{ assetId: factory.uuid(), value: {} }] });
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.badRequest(
expect.arrayContaining(['[items.0.key] Invalid input: expected string, received undefined']),
),
factory.responses.validationError([
{ path: ['items', 0, 'key'], message: 'Invalid input: expected string, received undefined' },
]),
);
});
@@ -159,7 +161,9 @@ describe(AssetController.name, () => {
.delete('/assets/metadata')
.send({ items: [{ assetId: '123', key: 'test' }] });
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 () => {
@@ -168,9 +172,9 @@ describe(AssetController.name, () => {
.send({ items: [{ assetId: factory.uuid() }] });
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.badRequest(
expect.arrayContaining(['[items.0.key] Invalid input: expected string, received undefined']),
),
factory.responses.validationError([
{ 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 () => {
const { status, body } = await request(ctx.getHttpServer()).put(`/assets/123`);
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 () => {
for (const test of [
{ latitude: 12 },
{ longitude: 12 },
{ latitude: 12, longitude: 'abc' },
{ latitude: 'abc', longitude: 12 },
{ latitude: null, longitude: 12 },
{ latitude: 12, longitude: null },
{ latitude: 91, longitude: 12 },
{ latitude: -91, longitude: 12 },
{ latitude: 12, longitude: -181 },
{ latitude: 12, longitude: 181 },
]) {
for (const [test, errors] of [
[{ latitude: 12 }, [{ path: [], message: 'Latitude and longitude must be provided together' }]],
[{ longitude: 12 }, [{ path: [], message: 'Latitude and longitude must be provided together' }]],
[
{ latitude: 12, longitude: 'abc' },
[{ path: ['longitude'], message: 'Invalid input: expected number, received string' }],
],
[
{ latitude: 'abc', longitude: 12 },
[{ path: ['latitude'], message: 'Invalid input: expected number, received string' }],
],
[
{ latitude: null, longitude: 12 },
[{ path: ['latitude'], message: 'Invalid input: expected number, received null' }],
],
[
{ latitude: 12, longitude: null },
[{ path: ['longitude'], message: 'Invalid input: expected number, received null' }],
],
[{ latitude: 91, longitude: 12 }, [{ path: ['latitude'], message: 'Too big: expected number to be <=90' }]],
[{ latitude: -91, longitude: 12 }, [{ path: ['latitude'], message: 'Too small: expected number to be >=-90' }]],
[
{ latitude: 12, longitude: -181 },
[{ path: ['longitude'], message: 'Too small: expected number to be >=-180' }],
],
[{ latitude: 12, longitude: 181 }, [{ path: ['longitude'], message: 'Too big: expected number to be <=180' }]],
] as const) {
const { status, body } = await request(ctx.getHttpServer()).put(`/assets/${factory.uuid()}`).send(test);
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest());
expect(body).toEqual(factory.responses.validationError(errors));
}
});
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);
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 () => {
const { status, body } = await request(ctx.getHttpServer()).put(`/assets/123/metadata`).send({ items: [] });
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 () => {
const { status, body } = await request(ctx.getHttpServer()).put(`/assets/${factory.uuid()}/metadata`).send({});
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 () => {
@@ -276,7 +307,9 @@ describe(AssetController.name, () => {
.send({ items: [{ value: { some: 'value' } }] });
expect(status).toBe(400);
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 }] });
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.badRequest(
expect.arrayContaining(['[items.0.value] Invalid input: expected record, received null']),
),
factory.responses.validationError([
{ 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 () => {
const { status, body } = await request(ctx.getHttpServer()).get(`/assets/123/metadata/mobile-app`);
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(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 () => {
@@ -398,13 +431,12 @@ describe(AssetController.name, () => {
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.badRequest(
expect.arrayContaining([
expect.stringContaining(
"[edits.0.parameters] Invalid parameters for action 'rotate', expecting keys: angle",
),
]),
),
factory.responses.validationError([
{
path: ['edits', 0, 'parameters'],
message: expect.stringContaining("Invalid parameters for action 'rotate', expecting keys: angle"),
},
]),
);
});
@@ -413,7 +445,11 @@ describe(AssetController.name, () => {
.put(`/assets/${factory.uuid()}/edits`)
.send({ edits: [] });
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 () => {
const { status, body } = await request(ctx.getHttpServer()).delete(`/assets/123/metadata/mobile-app`);
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 () => {
const { status, body } = await request(ctx.getHttpServer()).post('/auth/admin-sign-up').send({ name, password });
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 () => {
const { status, body } = await request(ctx.getHttpServer()).post('/auth/admin-sign-up').send({ name, email });
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 () => {
const { status, body } = await request(ctx.getHttpServer()).post('/auth/admin-sign-up').send({ email, password });
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 () => {
@@ -48,7 +56,9 @@ describe(AuthController.name, () => {
.post('/auth/admin-sign-up')
.send({ name, email: 'immich', password });
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 () => {
@@ -73,9 +83,9 @@ describe(AuthController.name, () => {
const { status, body } = await request(ctx.getHttpServer()).post('/auth/login').send({ name: 'admin' });
expect(status).toBe(400);
expect(body).toEqual(
errorDto.badRequest([
'[email] Invalid input: expected email, received undefined',
'[password] Invalid input: expected string, received undefined',
errorDto.validationError([
{ path: ['email'], message: 'Invalid input: expected email, received undefined' },
{ path: ['password'], message: 'Invalid input: expected string, received undefined' },
]),
);
});
@@ -85,7 +95,9 @@ describe(AuthController.name, () => {
.post('/auth/login')
.send({ name: 'admin', email: null, password: 'password' });
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 () => {
@@ -93,7 +105,9 @@ describe(AuthController.name, () => {
.post('/auth/login')
.send({ name: 'admin', email: 'admin@immich.cloud', password: null });
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 () => {
@@ -104,7 +118,9 @@ describe(AuthController.name, () => {
.send({ name: 'admin', email: [], password: 'password' });
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 () => {
@@ -195,19 +211,31 @@ describe(AuthController.name, () => {
it('should reject 5 digits', async () => {
const { status, body } = await request(ctx.getHttpServer()).post('/auth/pin-code').send({ pinCode: '12345' });
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 () => {
const { status, body } = await request(ctx.getHttpServer()).post('/auth/pin-code').send({ pinCode: '1234567' });
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 () => {
const { status, body } = await request(ctx.getHttpServer()).post('/auth/pin-code').send({ pinCode: 'A12345' });
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 () => {
const { status, body } = await request(ctx.getHttpServer()).delete(`/duplicates/123`);
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 })
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Delete a duplicate',
description: 'Delete a single duplicate asset specified by its ID.',
summary: 'Dismiss a duplicate group',
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'),
})
deleteDuplicate(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
@@ -31,7 +31,9 @@ describe(MaintenanceController.name, () => {
});
expect(status).toBe(400);
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();
});
@@ -47,7 +47,11 @@ describe(MemoryController.name, () => {
});
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 () => {
@@ -81,7 +85,7 @@ describe(MemoryController.name, () => {
it('should require a valid id', async () => {
const { status, body } = await request(ctx.getHttpServer()).get(`/memories/invalid`);
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 () => {
const { status, body } = await request(ctx.getHttpServer()).put(`/memories/invalid`);
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 () => {
const { status, body } = await request(ctx.getHttpServer()).put(`/memories/${factory.uuid()}`).send({});
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 () => {
const { status, body } = await request(ctx.getHttpServer()).put(`/memories/invalid/assets`).send({ ids: [] });
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 () => {
@@ -128,7 +134,7 @@ describe(MemoryController.name, () => {
.put(`/memories/${factory.uuid()}/assets`)
.send({ ids: ['invalid'] });
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 () => {
const { status, body } = await request(ctx.getHttpServer()).delete(`/memories/invalid/assets`);
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 () => {
@@ -149,7 +155,7 @@ describe(MemoryController.name, () => {
.delete(`/memories/${factory.uuid()}/assets`)
.send({ ids: ['invalid'] });
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' })
.set('Authorization', `Bearer token`);
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 () => {
const { status, body } = await request(ctx.getHttpServer()).put(`/notifications`).send({ ids: true });
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 () => {
@@ -53,7 +59,9 @@ describe(NotificationController.name, () => {
.put(`/notifications`)
.send({ ids: [true] });
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 () => {
@@ -75,7 +83,7 @@ describe(NotificationController.name, () => {
it('should require a valid uuid', async () => {
const { status, body } = await request(ctx.getHttpServer()).get(`/notifications/123`);
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`);
expect(status).toBe(400);
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`);
expect(status).toBe(400);
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' })
.set('Authorization', `Bearer token`);
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 })
.set('Authorization', `Bearer token`);
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`)
.set('Authorization', `Bearer token`);
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' })
.set('Authorization', `Bearer token`);
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 () => {
@@ -44,7 +44,7 @@ describe(PersonController.name, () => {
.query({ closestAssetId: 'invalid' })
.set('Authorization', `Bearer token`);
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')
.send({ ids: ['invalid'] });
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 () => {
@@ -104,7 +104,9 @@ describe(PersonController.name, () => {
it('should require a valid uuid', async () => {
const { status, body } = await request(ctx.getHttpServer()).put(`/people/123`);
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 () => {
@@ -113,7 +115,9 @@ describe(PersonController.name, () => {
.send({ name: null })
.set('Authorization', `Bearer token`);
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 () => {
@@ -122,7 +126,7 @@ describe(PersonController.name, () => {
.send({ featureFaceAssetId: 'invalid' })
.set('Authorization', `Bearer token`);
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 () => {
@@ -131,7 +135,11 @@ describe(PersonController.name, () => {
.send({ isFavorite: 'invalid' })
.set('Authorization', `Bearer token`);
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 () => {
@@ -140,7 +148,9 @@ describe(PersonController.name, () => {
.send({ isHidden: 'invalid' })
.set('Authorization', `Bearer token`);
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 () => {
@@ -154,7 +164,11 @@ describe(PersonController.name, () => {
.put(`/people/${factory.uuid()}`)
.send({ birthDate: false });
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 () => {
@@ -162,7 +176,9 @@ describe(PersonController.name, () => {
.put(`/people/${factory.uuid()}`)
.send({ birthDate: 123_456 });
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 () => {
@@ -170,7 +186,9 @@ describe(PersonController.name, () => {
.put(`/people/${factory.uuid()}`)
.send({ birthDate: '9999-01-01' });
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 () => {
const { status, body } = await request(ctx.getHttpServer()).delete(`/people/invalid`);
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 () => {
@@ -27,31 +27,41 @@ describe(SearchController.name, () => {
it('should reject page as a string', async () => {
const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ page: 'abc' });
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 () => {
const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ page: -10 });
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 () => {
const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ page: 0 });
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 () => {
const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ size: 'abc' });
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 () => {
const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ size: -1 });
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 () => {
@@ -60,7 +70,9 @@ describe(SearchController.name, () => {
.send({ visibility: 'immich' });
expect(status).toBe(400);
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')
.send({ isFavorite: 'immich' });
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 () => {
@@ -77,7 +93,11 @@ describe(SearchController.name, () => {
.post('/search/metadata')
.send({ isEncoded: 'immich' });
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 () => {
@@ -85,13 +105,19 @@ describe(SearchController.name, () => {
.post('/search/metadata')
.send({ isOffline: 'immich' });
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 () => {
const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ isMotion: 'immich' });
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', () => {
@@ -105,7 +131,11 @@ describe(SearchController.name, () => {
.post('/search/random')
.send({ withStacked: 'immich' });
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 () => {
@@ -113,7 +143,11 @@ describe(SearchController.name, () => {
.post('/search/random')
.send({ withPeople: 'immich' });
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 () => {
const { status, body } = await request(ctx.getHttpServer()).get('/search/person').send({});
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 () => {
const { status, body } = await request(ctx.getHttpServer()).get('/search/places').send({});
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 () => {
const { status, body } = await request(ctx.getHttpServer()).get('/search/suggestions').send({});
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')
.send({ types: ['invalid'] });
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();
});
});
@@ -57,7 +61,9 @@ describe(SyncController.name, () => {
const acks = Array.from({ length: 1001 }, (_, i) => `ack-${i}`);
const { status, body } = await request(ctx.getHttpServer()).post('/sync/ack').send({ acks });
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();
});
});
@@ -73,7 +79,11 @@ describe(SyncController.name, () => {
.delete('/sync/ack')
.send({ types: ['invalid'] });
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();
});
});
@@ -67,8 +67,11 @@ describe(SystemConfigController.name, () => {
const { status, body } = await request(ctx.getHttpServer()).put('/system-config').send(config);
expect(status).toBe(400);
expect(body).toEqual(
errorDto.badRequest([
'[nightlyTasks.startTime] Invalid input: expected string in HH:mm format, received string',
errorDto.validationError([
{
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);
expect(status).toBe(400);
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);
expect(status).toBe(400);
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 () => {
const { status, body } = await request(ctx.getHttpServer()).get(`/tags/123`);
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' });
expect(status).toBe(400);
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')
.query({ bbox: '1,2,3,invalid' });
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);
expect(status).toBe(400);
expect(body).toEqual(
errorDto.badRequest(
expect.arrayContaining(['[quotaSizeInBytes] Invalid input: expected int, received number']),
),
errorDto.validationError([
{ path: ['quotaSizeInBytes'], message: 'Invalid input: expected int, received number' },
]),
);
});
@@ -98,9 +98,9 @@ describe(UserAdminController.name, () => {
.send(dto);
expect(status).toBe(400);
expect(body).toEqual(
errorDto.badRequest(
expect.arrayContaining(['[quotaSizeInBytes] Invalid input: expected int, received number']),
),
errorDto.validationError([
{ path: ['quotaSizeInBytes'], message: 'Invalid input: expected int, received number' },
]),
);
});
});
@@ -125,9 +125,9 @@ describe(UserAdminController.name, () => {
.send({ quotaSizeInBytes: 1.2 });
expect(status).toBe(400);
expect(body).toEqual(
errorDto.badRequest(
expect.arrayContaining(['[quotaSizeInBytes] Invalid input: expected int, received number']),
),
errorDto.validationError([
{ path: ['quotaSizeInBytes'], message: 'Invalid input: expected int, received number' },
]),
);
});
@@ -43,15 +43,17 @@ describe(UserController.name, () => {
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 () => {
const dto = { [key]: null };
const { status, body } = await request(ctx.getHttpServer())
.put(`/users/me`)
.set('Authorization', `Bearer token`)
.send(dto);
.send({ [key]: null });
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 {
AlbumUserRole,
AssetFileType,
AssetOrder,
AssetType,
AssetVisibility,
ChecksumAlgorithm,
@@ -196,7 +195,6 @@ export type SharedLink = {
};
export type Album = Selectable<AlbumTable> & {
order: AssetOrder;
assets: ShallowDehydrateObject<Selectable<AssetTable>>[];
};
@@ -40,16 +40,16 @@ export class GlobalExceptionFilter implements ExceptionFilter<Error> {
if (error instanceof ZodValidationException || error instanceof ZodSerializationException) {
const zodError = error.getZodError();
if (zodError instanceof ZodError && zodError.issues.length > 0) {
body['message'] = zodError.issues.map((issue) =>
issue.path.length > 0 ? `[${issue.path.join('.')}] ${issue.message}` : issue.message,
);
return {
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['statusCode'];
delete body['errors'];
return { status, body };
}
+13 -52
View File
@@ -14,7 +14,7 @@ import { InjectKysely } from 'nestjs-kysely';
import { columns } from 'src/database';
import { Chunked, ChunkedArray, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators';
import { AlbumUserCreateDto } from 'src/dtos/album.dto';
import { AlbumUserRole, AssetOrder } from 'src/enum';
import { AlbumUserRole } from 'src/enum';
import { DB } from 'src/schema';
import { AlbumTable } from 'src/schema/tables/album.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),
);
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()
export class AlbumRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
@@ -112,7 +95,6 @@ export class AlbumRepository {
.where('album.id', '=', id)
.where('album.deletedAt', 'is', null)
.select(withAlbumUsers(authUserId))
.select(withOrder(authUserId))
.select(withSharedLink)
.$if(options.withAssets, (eb) => eb.select(withAssets))
.$narrowType<{ assets: NotNull }>()
@@ -136,7 +118,6 @@ export class AlbumRepository {
.where('album_asset.assetId', '=', assetId)
.where('album.deletedAt', 'is', null)
.select(withAlbumUsers(ownerId))
.select(withOrder(ownerId))
.orderBy('album.createdAt', 'desc')
.execute();
}
@@ -214,7 +195,6 @@ export class AlbumRepository {
.on('album_user.role', '=', sql.lit(AlbumUserRole.Owner)),
)
.where('album.deletedAt', 'is', null)
.select('album_user.order as order')
.select(withAlbumUsers(ownerId))
.select(withSharedLink)
.orderBy('album.createdAt', 'desc')
@@ -259,7 +239,6 @@ export class AlbumRepository {
)
.where('album.deletedAt', 'is', null)
.select(withAlbumUsers(ownerId))
.select(withOrder(ownerId))
.select(withSharedLink)
.orderBy('album.createdAt', 'desc')
.execute();
@@ -292,7 +271,6 @@ export class AlbumRepository {
.where(({ not, exists, selectFrom }) =>
not(exists(selectFrom('shared_link').whereRef('shared_link.albumId', '=', 'album.id'))),
)
.select('album_user.order as order')
.select(withSharedLink)
.select(withAlbumUsers(ownerId))
.orderBy('album.createdAt', 'desc')
@@ -372,13 +350,13 @@ export class AlbumRepository {
params: [
{ albumName: DummyValue.STRING },
[],
[{ userId: DummyValue.UUID, role: AlbumUserRole.Owner, order: AssetOrder.Desc }, DummyValue.UUID],
[{ userId: DummyValue.UUID, role: AlbumUserRole.Owner }, DummyValue.UUID],
],
})
async create(
album: Insertable<AlbumTable>,
assetIds: string[],
albumUsers: (AlbumUserCreateDto & { order?: AssetOrder })[],
albumUsers: AlbumUserCreateDto[],
authUserId: string,
) {
if (!albumUsers.some((u) => u.role === AlbumUserRole.Owner)) {
@@ -387,14 +365,12 @@ export class AlbumRepository {
const userIds = albumUsers.map((u) => u.userId);
const roles = albumUsers.map((u) => u.role);
const orders = albumUsers.map((u) => u.order ?? AssetOrder.Desc);
const result = await this.db
.with('album', (db) => db.insertInto('album').values(album).returningAll())
.with('album_user', (db) =>
db
.insertInto('album_user')
.columns(['albumId', 'userId', 'role', 'order'])
.expression((eb) =>
eb
.selectFrom('album')
@@ -402,15 +378,13 @@ export class AlbumRepository {
ref('album.id').as('albumId'),
sql`unnest(${userIds}::uuid[])`.as('userId'),
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) =>
db
.insertInto('album_asset')
.columns(['albumId', 'assetId'])
.expression((eb) =>
eb
.selectFrom('album')
@@ -422,7 +396,6 @@ export class AlbumRepository {
.selectFrom('album')
.selectAll('album')
.select(withAlbumUsers(authUserId))
.select(withOrder(authUserId))
.select(withAssets)
.$narrowType<{ assets: NotNull }>()
.executeTakeFirstOrThrow();
@@ -430,27 +403,15 @@ export class AlbumRepository {
return result;
}
update(id: string, album: Updateable<AlbumTable>, authUserId: string, order?: AssetOrder) {
return this.db.transaction().execute(async (db) => {
if (order !== undefined) {
await db
.updateTable('album_user')
.set({ order })
.where('albumId', '=', id)
.where('userId', '=', authUserId)
.execute();
}
return db
.updateTable('album')
.set(album)
.where('album.id', '=', id)
.returningAll('album')
.returning(withSharedLink)
.returning(withAlbumUsers(authUserId))
.returning(withOrder(authUserId))
.executeTakeFirstOrThrow();
});
update(id: string, album: Updateable<AlbumTable>, authUserId: string) {
return this.db
.updateTable('album')
.set(album)
.where('album.id', '=', id)
.returningAll('album')
.returning(withSharedLink)
.returning(withAlbumUsers(authUserId))
.executeTakeFirstOrThrow();
}
async delete(id: string): Promise<void> {
@@ -5,7 +5,7 @@ import _ from 'lodash';
import { InjectKysely } from 'nestjs-kysely';
import { Album, columns } from 'src/database';
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 { AssetExifTable } from 'src/schema/tables/asset-exif.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'>) => {
return eb
.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')
.select((eb) =>
eb.fn.coalesce('album_order.order', sql<AssetOrder>`${AssetOrder.Desc}`).as('order'),
)
.whereRef('album.id', '=', 'shared_link.albumId')
.where('album.deletedAt', 'is', null);
};
@@ -115,7 +107,7 @@ export class SharedLinkRepository {
.as('assets'),
)
.select((eb) => eb.fn.toJson('owner').as('owner'))
.groupBy(['album.id', 'album_order.order', sql`"owner".*`])
.groupBy(['album.id', sql`"owner".*`])
.as('album'),
(join) => join.onTrue(),
)
+2 -2
View File
@@ -170,7 +170,7 @@ class AlbumSync extends BaseSync {
const userId = options.userId;
return this.upsertQuery('album', options)
.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)
.select([
'album.id',
@@ -180,7 +180,7 @@ class AlbumSync extends BaseSync {
'album.updatedAt',
'album.albumThumbnailAssetId as thumbnailAssetId',
'album.isActivityEnabled',
'album_users.order as order',
'album.order',
'album.updateId',
])
.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,
} from '@immich/sql-tools';
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_after_insert, album_user_delete_audit } from 'src/schema/functions';
import { AlbumTable } from 'src/schema/tables/album.table';
@@ -58,9 +58,6 @@ export class AlbumUserTable {
@Column({ enum: album_user_role_enum, default: AlbumUserRole.Editor })
role!: Generated<AlbumUserRole>;
@Column({ default: AssetOrder.Desc })
order!: Generated<AssetOrder>;
@CreateIdColumn({ index: true })
createId!: Generated<string>;
+4
View File
@@ -10,6 +10,7 @@ import {
UpdateDateColumn,
} from '@immich/sql-tools';
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { AssetOrder } from 'src/enum';
import { AssetTable } from 'src/schema/tables/asset.table';
@Table({ name: 'album' })
@@ -44,6 +45,9 @@ export class AlbumTable {
@Column({ type: 'boolean', default: true })
isActivityEnabled!: Generated<boolean>;
@Column({ default: AssetOrder.Desc })
order!: Generated<AssetOrder>;
@UpdateIdColumn({ index: true })
updateId!: Generated<string>;
}
+8 -27
View File
@@ -182,19 +182,19 @@ describe(AlbumService.name, () => {
{
albumName: 'test',
description: 'description',
order: album.order,
albumThumbnailAssetId: assetId,
},
[assetId],
[
{ userId: owner.id, role: AlbumUserRole.Owner, order: AssetOrder.Desc },
{ userId: albumUser.userId, role: AlbumUserRole.Editor, order: AssetOrder.Desc },
{ userId: owner.id, role: AlbumUserRole.Owner },
{ userId: albumUser.userId, role: AlbumUserRole.Editor },
],
owner.id,
);
expect(mocks.user.get).toHaveBeenCalledWith(albumUser.userId, {});
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.event.emit).toHaveBeenCalledTimes(1);
expect(mocks.event.emit).toHaveBeenCalledWith('AlbumInvite', {
@@ -238,19 +238,16 @@ describe(AlbumService.name, () => {
{
albumName: album.albumName,
description: album.description,
order: 'asc',
albumThumbnailAssetId: assetId,
},
[assetId],
[
{ userId: owner.id, role: AlbumUserRole.Owner, order: 'asc' },
{ ...albumUser, order: 'asc' },
],
[{ userId: owner.id, role: AlbumUserRole.Owner }, albumUser],
owner.id,
);
expect(mocks.user.get).toHaveBeenCalledWith(albumUser.userId, {});
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.event.emit).toHaveBeenCalledWith('AlbumInvite', {
id: album.id,
@@ -293,10 +290,11 @@ describe(AlbumService.name, () => {
{
albumName: album.albumName,
description: album.description,
order: 'desc',
albumThumbnailAssetId: assetId,
},
[assetId],
[{ userId: owner.id, role: AlbumUserRole.Owner, order: 'desc' }],
[{ userId: owner.id, role: AlbumUserRole.Owner }],
owner.id,
);
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).toHaveBeenCalledWith(
album.id,
expect.objectContaining({ id: album.id, albumName: 'new album name' }),
{ id: album.id, albumName: 'new album name' },
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', () => {
@@ -480,7 +464,6 @@ describe(AlbumService.name, () => {
mocks.album.getById.mockResolvedValue(getForAlbum(album));
mocks.album.update.mockResolvedValue(getForAlbum(album));
mocks.user.get.mockResolvedValue(user);
mocks.user.getMetadata.mockResolvedValue([]);
mocks.albumUser.create.mockResolvedValue(AlbumUserFactory.from().album(album).user(user).build());
await sut.addUsers(AuthFactory.create(owner), album.id, { albumUsers: [{ userId: user.id }] });
@@ -488,9 +471,7 @@ describe(AlbumService.name, () => {
expect(mocks.albumUser.create).toHaveBeenCalledWith({
userId: user.id,
albumId: album.id,
order: AssetOrder.Desc,
});
expect(mocks.user.getMetadata).toHaveBeenCalledWith(user.id);
expect(mocks.event.emit).toHaveBeenCalledWith('AlbumInvite', {
id: album.id,
userId: user.id,
+4 -14
View File
@@ -124,22 +124,16 @@ export class AlbumService extends BaseService {
const assetIds = [...allowedAssetIdsSet].map((id) => 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(
{
albumName: dto.albumName,
description: dto.description,
albumThumbnailAssetId: assetIds[0] || null,
order: getPreferences(userMetadata).albums.defaultAssetOrder,
},
assetIds,
[{ userId: auth.user.id, role: AlbumUserRole.Owner, order: ownerOrder }, ...albumUsersWithOrder],
[{ userId: auth.user.id, role: AlbumUserRole.Owner }, ...albumUsers],
auth.user.id,
);
@@ -161,7 +155,6 @@ export class AlbumService extends BaseService {
throw new BadRequestException('Invalid album thumbnail');
}
}
const updatedAlbum = await this.albumRepository.update(
album.id,
{
@@ -170,9 +163,9 @@ export class AlbumService extends BaseService {
description: dto.description,
albumThumbnailAssetId: dto.albumThumbnailAssetId,
isActivityEnabled: dto.isActivityEnabled,
order: dto.order,
},
auth.user.id,
dto.order,
);
return mapAlbum({ ...updatedAlbum, assets: album.assets });
@@ -314,10 +307,7 @@ export class AlbumService extends BaseService {
throw new BadRequestException('Invalid user');
}
const userMetadata = await this.userRepository.getMetadata(userId);
const order = getPreferences(userMetadata).albums.defaultAssetOrder;
await this.albumUserRepository.create({ userId, albumId: id, role, order });
await this.albumUserRepository.create({ userId, albumId: id, role });
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 { MapAsset } from 'src/dtos/asset-response.dto';
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', () => {
it('should handle mixed success and failure', async () => {
const asset = AssetFactory.create();
+2
View File
@@ -82,10 +82,12 @@ export class DuplicateService extends BaseService {
}
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);
}
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);
}
+1 -2
View File
@@ -1,5 +1,5 @@
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 { AlbumFactory } from 'test/factories/album.factory';
import { build } from 'test/factories/builder.factory';
@@ -24,7 +24,6 @@ export class AlbumUserFactory {
albumId: newUuid(),
userId: newUuid(),
role: AlbumUserRole.Editor,
order: AssetOrder.Desc,
createId: newUuidV7(),
createdAt: newDate(),
updateId: newUuidV7(),
+1 -1
View File
@@ -15,7 +15,7 @@ export class AlbumFactory {
#albumUsers: AlbumUserFactory[] = [];
#assets: AssetFactory[] = [];
private constructor(private readonly value: Selectable<AlbumTable> & { order: AssetOrder }) {}
private constructor(private readonly value: Selectable<AlbumTable>) {}
static create(dto: AlbumLike = {}) {
return AlbumFactory.from(dto).build();
+1 -2
View File
@@ -1,5 +1,4 @@
import { Selectable } from 'kysely';
import { AssetOrder } from 'src/enum';
import { OAuthProfile } from 'src/repositories/oauth.repository';
import { ActivityTable } from 'src/schema/tables/activity.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 AssetEditLike = Partial<Selectable<AssetEditTable>>;
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 SharedLinkLike = Partial<Selectable<SharedLinkTable>>;
export type UserLike = Partial<Selectable<UserTable>>;
+4
View File
@@ -25,6 +25,10 @@ export const errorDto = {
badRequest: (message: any = null) => ({
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: {
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 { AssetFileType, Permission, UserStatus } from 'src/enum';
import { v4, v7 } from 'uuid';
import { expect } from 'vitest';
export const newUuid = () => v4();
export const newUuids = () =>
@@ -248,5 +249,9 @@ export const factory = {
badRequest: (message: any = null) => ({
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}
</AdaptiveImage>
{#if assetViewerManager.isFaceEditMode && assetViewerManager.imgRef}
<FaceEditor htmlElement={assetViewerManager.imgRef} {containerWidth} {containerHeight} assetId={asset.id} />
{#if assetViewerManager.isFaceEditMode && assetViewerManager.imgRef && asset.width && asset.height}
<FaceEditor
assetSize={{ width: asset.width, height: asset.height }}
containerSize={{ width: containerWidth, height: containerHeight }}
assetId={asset.id}
/>
{/if}
</div>
@@ -140,9 +140,40 @@
let containerHeight = $state(0);
$effect(() => {
if (assetViewerManager.isFaceEditMode) {
videoPlayer?.pause();
if (!assetViewerManager.isFaceEditMode || !videoPlayer) {
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>
@@ -248,7 +279,11 @@
{/if}
{#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}
</div>
@@ -4,7 +4,7 @@
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import FaceCreateTagModal from '$lib/modals/CreateFaceModal.svelte';
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 { createFace, getAllPeople, type PersonResponseDto } from '@immich/sdk';
import { Button, Input, modalManager, toastManager } from '@immich/ui';
@@ -14,13 +14,12 @@
import { t } from 'svelte-i18n';
type Props = {
htmlElement: HTMLImageElement | HTMLVideoElement;
containerWidth: number;
containerHeight: number;
assetSize: Size;
containerSize: Size;
assetId: string;
};
let { htmlElement, containerWidth, containerHeight, assetId }: Props = $props();
let { assetSize, containerSize, assetId }: Props = $props();
let canvasEl: HTMLCanvasElement | undefined = $state();
let canvas: Canvas | undefined = $state();
@@ -54,7 +53,7 @@
};
const setupCanvas = () => {
if (!canvasEl || !htmlElement) {
if (!canvasEl) {
return;
}
@@ -86,24 +85,14 @@
searchInputEl?.focus();
});
const imageContentMetrics = $derived.by(() => {
const natural = getNaturalSize(htmlElement);
const container = { width: containerWidth, height: containerHeight };
const { width: contentWidth, height: contentHeight } = scaleToFit(natural, container);
return {
contentWidth,
contentHeight,
offsetX: (containerWidth - contentWidth) / 2,
offsetY: (containerHeight - contentHeight) / 2,
};
});
const imageContentMetrics = $derived(computeContentMetrics(assetSize, containerSize));
const setDefaultFaceRectanglePosition = (faceRect: Rect) => {
const { offsetX, offsetY } = imageContentMetrics;
const { offsetX, offsetY, contentWidth, contentHeight } = imageContentMetrics;
faceRect.set({
top: offsetY + 200,
left: offsetX + 200,
top: offsetY + contentHeight / 2 - 56,
left: offsetX + contentWidth / 2 - 56,
});
faceRect.setCoords();
@@ -116,8 +105,8 @@
}
canvas.setDimensions({
width: containerWidth,
height: containerHeight,
width: containerSize.width,
height: containerSize.height,
});
if (!faceRect) {
@@ -167,6 +156,9 @@
const gap = 15;
const padding = faceRect.padding ?? 0;
const rawBox = faceRect.getBoundingRect();
if (Number.isNaN(rawBox.left) || Number.isNaN(rawBox.width)) {
return;
}
const faceBox = {
left: rawBox.left - padding,
top: rawBox.top - padding,
@@ -175,11 +167,11 @@
};
const selectorWidth = faceSelectorEl.offsetWidth;
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 clampTop = (top: number) => clamp(top, gap, containerHeight - selectorHeight - gap);
const clampLeft = (left: number) => clamp(left, gap, containerWidth - selectorWidth - gap);
const clampTop = (top: number) => clamp(top, gap, containerSize.height - selectorHeight - gap);
const clampLeft = (left: number) => clamp(left, gap, containerSize.width - selectorWidth - gap);
const overlapArea = (position: { top: number; left: number }) => {
const selectorRight = position.left + selectorWidth;
@@ -238,45 +230,37 @@
});
const getFaceCroppedCoordinates = () => {
if (!faceRect || !htmlElement) {
if (!faceRect || imageContentMetrics.contentWidth === 0) {
return;
}
const { left, top, width, height } = faceRect.getBoundingRect();
const { offsetX, offsetY, contentWidth, contentHeight } = imageContentMetrics;
const natural = getNaturalSize(htmlElement);
const scaleX = natural.width / contentWidth;
const scaleY = natural.height / contentHeight;
const imageX = (left - offsetX) * scaleX;
const imageY = (top - offsetY) * scaleY;
const imageRect = mapContentRectToNatural(faceRect.getBoundingRect(), imageContentMetrics, assetSize);
return {
imageWidth: natural.width,
imageHeight: natural.height,
x: Math.floor(imageX),
y: Math.floor(imageY),
width: Math.floor(width * scaleX),
height: Math.floor(height * scaleY),
imageWidth: assetSize.width,
imageHeight: assetSize.height,
x: Math.floor(imageRect.left),
y: Math.floor(imageRect.top),
width: Math.floor(imageRect.width),
height: Math.floor(imageRect.height),
};
};
type FaceCoordinates = NonNullable<ReturnType<typeof getFaceCroppedCoordinates>>;
const getFacePreviewUrl = (data: FaceCoordinates) => {
if (!htmlElement) {
const imgRef = assetViewerManager.imgRef;
if (!imgRef || imageContentMetrics.contentWidth === 0) {
return;
}
const natural = getNaturalSize(htmlElement);
if (natural.width <= 0 || natural.height <= 0) {
return;
}
const scaleX = imgRef.naturalWidth / assetSize.width;
const scaleY = imgRef.naturalHeight / assetSize.height;
const x = clamp(data.x, 0, natural.width - 1);
const y = clamp(data.y, 0, natural.height - 1);
const width = clamp(data.width, 1, natural.width - x);
const height = clamp(data.height, 1, natural.height - y);
const x = clamp(Math.floor(data.x * scaleX), 0, imgRef.naturalWidth - 1);
const y = clamp(Math.floor(data.y * scaleY), 0, imgRef.naturalHeight - 1);
const width = clamp(Math.floor(data.width * scaleX), 1, imgRef.naturalWidth - x);
const height = clamp(Math.floor(data.height * scaleY), 1, imgRef.naturalHeight - y);
if (width <= 0 || height <= 0) {
return;
@@ -292,7 +276,7 @@
}
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');
} catch {
return;
+67 -33
View File
@@ -1,18 +1,15 @@
import {
getContentMetrics,
computeContentMetrics,
getNaturalSize,
mapContentRectToNatural,
mapNormalizedRectToContent,
mapNormalizedToContent,
scaleToCover,
scaleToFit,
} from '$lib/utils/container-utils';
const mockImage = (props: {
naturalWidth: number;
naturalHeight: number;
width: number;
height: number;
}): HTMLImageElement => props as unknown as HTMLImageElement;
const mockImage = (props: { naturalWidth: number; naturalHeight: number }): HTMLImageElement =>
props as unknown as HTMLImageElement;
const mockVideo = (props: {
videoWidth: number;
@@ -49,48 +46,85 @@ describe('scaleToFit', () => {
});
});
describe('getContentMetrics', () => {
it('should compute zero offsets when aspect ratios match', () => {
const img = mockImage({ naturalWidth: 1600, naturalHeight: 900, width: 800, height: 450 });
expect(getContentMetrics(img)).toEqual({
describe('computeContentMetrics', () => {
it('should return zero metrics for zero-width content', () => {
expect(computeContentMetrics({ width: 0, height: 1080 }, { width: 800, height: 600 })).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,
contentHeight: 450,
offsetX: 0,
offsetY: 0,
});
});
});
it('should compute horizontal letterbox offsets for tall image', () => {
const img = mockImage({ naturalWidth: 1000, naturalHeight: 2000, width: 800, height: 600 });
const metrics = getContentMetrics(img);
expect(metrics.contentWidth).toBe(300);
expect(metrics.contentHeight).toBe(600);
expect(metrics.offsetX).toBe(250);
expect(metrics.offsetY).toBe(0);
describe('mapContentRectToNatural', () => {
it('should map a full-content rect back to natural size', () => {
const metrics = { contentWidth: 800, contentHeight: 400, offsetX: 0, offsetY: 100 };
const rect = mapContentRectToNatural({ left: 0, top: 100, width: 800, height: 400 }, metrics, {
width: 2000,
height: 1000,
});
expect(rect).toEqual({ left: 0, top: 0, width: 2000, height: 1000 });
});
it('should compute vertical letterbox offsets for wide image', () => {
const img = mockImage({ naturalWidth: 2000, naturalHeight: 1000, width: 800, height: 600 });
const metrics = getContentMetrics(img);
expect(metrics.contentWidth).toBe(800);
expect(metrics.contentHeight).toBe(400);
expect(metrics.offsetX).toBe(0);
expect(metrics.offsetY).toBe(100);
it('should map a centered sub-rect to natural coordinates', () => {
const metrics = { contentWidth: 800, contentHeight: 400, offsetX: 0, offsetY: 100 };
const rect = mapContentRectToNatural({ left: 200, top: 200, width: 400, height: 200 }, metrics, {
width: 2000,
height: 1000,
});
expect(rect).toEqual({ left: 500, top: 250, width: 1000, height: 500 });
});
it('should use clientWidth/clientHeight for video elements', () => {
const video = mockVideo({ videoWidth: 1920, videoHeight: 1080, clientWidth: 800, clientHeight: 600 });
const metrics = getContentMetrics(video);
expect(metrics.contentWidth).toBe(800);
expect(metrics.contentHeight).toBe(450);
expect(metrics.offsetX).toBe(0);
expect(metrics.offsetY).toBe(75);
it('should handle letterboxed content with horizontal offset', () => {
const metrics = { contentWidth: 300, contentHeight: 600, offsetX: 250, offsetY: 0 };
const rect = mapContentRectToNatural({ left: 250, top: 0, width: 300, height: 600 }, metrics, {
width: 1000,
height: 2000,
});
expect(rect).toEqual({ left: 0, top: 0, width: 1000, height: 2000 });
});
});
describe('getNaturalSize', () => {
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 });
});
+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 => {
if (element instanceof HTMLVideoElement) {
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 };
};
export const getContentMetrics = (element: HTMLImageElement | HTMLVideoElement): ContentMetrics => {
const natural = getNaturalSize(element);
const client = getElementSize(element);
const { width: contentWidth, height: contentHeight } = scaleToFit(natural, client);
export function computeContentMetrics(content: Size, container: Size): ContentMetrics {
if (content.width === 0 || content.height === 0) {
return { contentWidth: 0, contentHeight: 0, offsetX: 0, offsetY: 0 };
}
const { width: contentWidth, height: contentHeight } = scaleToFit(content, container);
return {
contentWidth,
contentHeight,
offsetX: (client.width - contentWidth) / 2,
offsetY: (client.height - contentHeight) / 2,
offsetX: (container.width - contentWidth) / 2,
offsetY: (container.height - contentHeight) / 2,
};
};
}
export function mapNormalizedToContent(point: Point, sizeOrMetrics: Size | ContentMetrics): Point {
if ('contentWidth' in sizeOrMetrics) {
@@ -109,3 +103,25 @@ export function mapNormalizedRectToContent(
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;
}
+10
View File
@@ -7,6 +7,9 @@ describe('formatGroupTitle', () => {
beforeAll(() => {
vi.useFakeTimers();
process.env.TZ = 'UTC';
});
beforeEach(() => {
vi.setSystemTime(new Date('2024-07-27T12:00:00Z'));
});
@@ -31,6 +34,13 @@ describe('formatGroupTitle', () => {
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', () => {
const date = parseUtcDate('2024-07-21T00:00:00Z');
locale.set('en');
+1 -1
View File
@@ -128,7 +128,7 @@ export function formatGroupTitle(_date: DateTime): string {
// Yesterday
if (today.minus({ days: 1 }).hasSame(date, 'day')) {
return date.toRelativeCalendar({ locale: get(locale) });
return date.toRelativeCalendar({ locale: get(locale), unit: 'days' });
}
// Last week