Compare commits

..

1 Commits

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