diff --git a/e2e/src/responses.ts b/e2e/src/responses.ts index 2ec7aecb0e..5fd887c44b 100644 --- a/e2e/src/responses.ts +++ b/e2e/src/responses.ts @@ -28,6 +28,10 @@ export const errorDto = { badRequest: (message: any = null) => ({ message: message ?? expect.anything(), }), + validationError: (errors?: ReadonlyArray<{ path: ReadonlyArray; message: string }>) => ({ + message: 'Validation failed', + errors: errors ? expect.arrayContaining(errors.map((e) => expect.objectContaining(e))) : expect.any(Array), + }), noPermission: { message: expect.stringContaining('Not found or no'), }, @@ -37,9 +41,6 @@ export const errorDto = { alreadyHasAdmin: { message: 'The server already has an admin', }, - invalidEmail: { - message: ['email must be an email'], - }, }; export const signupResponseDto = { diff --git a/e2e/src/specs/server/api/library.e2e-spec.ts b/e2e/src/specs/server/api/library.e2e-spec.ts index 719436a66d..ccb594610c 100644 --- a/e2e/src/specs/server/api/library.e2e-spec.ts +++ b/e2e/src/specs/server/api/library.e2e-spec.ts @@ -110,7 +110,9 @@ describe('/libraries', () => { }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[importPaths] Array must have unique items'])); + expect(body).toEqual( + errorDto.validationError([{ path: ['importPaths'], message: 'Array must have unique items' }]), + ); }); it('should not create an external library with duplicate exclusion patterns', async () => { @@ -125,7 +127,9 @@ describe('/libraries', () => { }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[exclusionPatterns] Array must have unique items'])); + expect(body).toEqual( + errorDto.validationError([{ path: ['exclusionPatterns'], message: 'Array must have unique items' }]), + ); }); }); @@ -157,7 +161,9 @@ describe('/libraries', () => { .send({ name: '' }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[name] Too small: expected string to have >=1 characters'])); + expect(body).toEqual( + errorDto.validationError([{ path: ['name'], message: 'Too small: expected string to have >=1 characters' }]), + ); }); it('should change the import paths', async () => { @@ -181,7 +187,9 @@ describe('/libraries', () => { .send({ importPaths: [''] }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[importPaths] Array items must not be empty'])); + expect(body).toEqual( + errorDto.validationError([{ path: ['importPaths'], message: 'Array items must not be empty' }]), + ); }); it('should reject duplicate import paths', async () => { @@ -191,7 +199,9 @@ describe('/libraries', () => { .send({ importPaths: ['/path', '/path'] }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[importPaths] Array must have unique items'])); + expect(body).toEqual( + errorDto.validationError([{ path: ['importPaths'], message: 'Array must have unique items' }]), + ); }); it('should change the exclusion pattern', async () => { @@ -215,7 +225,9 @@ describe('/libraries', () => { .send({ exclusionPatterns: ['**/*.jpg', '**/*.jpg'] }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[exclusionPatterns] Array must have unique items'])); + expect(body).toEqual( + errorDto.validationError([{ path: ['exclusionPatterns'], message: 'Array must have unique items' }]), + ); }); it('should reject an empty exclusion pattern', async () => { @@ -225,7 +237,9 @@ describe('/libraries', () => { .send({ exclusionPatterns: [''] }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[exclusionPatterns] Array items must not be empty'])); + expect(body).toEqual( + errorDto.validationError([{ path: ['exclusionPatterns'], message: 'Array items must not be empty' }]), + ); }); }); diff --git a/e2e/src/specs/server/api/map.e2e-spec.ts b/e2e/src/specs/server/api/map.e2e-spec.ts index c280deb134..86664b2dc4 100644 --- a/e2e/src/specs/server/api/map.e2e-spec.ts +++ b/e2e/src/specs/server/api/map.e2e-spec.ts @@ -109,7 +109,9 @@ describe('/map', () => { .get('/map/reverse-geocode?lon=123') .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[lat] Invalid input: expected number, received NaN'])); + expect(body).toEqual( + errorDto.validationError([{ path: ['lat'], message: 'Invalid input: expected number, received NaN' }]), + ); }); it('should throw an error if a lat is not a number', async () => { @@ -117,7 +119,9 @@ describe('/map', () => { .get('/map/reverse-geocode?lat=abc&lon=123.456') .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[lat] Invalid input: expected number, received NaN'])); + expect(body).toEqual( + errorDto.validationError([{ path: ['lat'], message: 'Invalid input: expected number, received NaN' }]), + ); }); it('should throw an error if a lat is out of range', async () => { @@ -125,7 +129,9 @@ describe('/map', () => { .get('/map/reverse-geocode?lat=91&lon=123.456') .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[lat] Too big: expected number to be <=90'])); + expect(body).toEqual( + errorDto.validationError([{ path: ['lat'], message: 'Too big: expected number to be <=90' }]), + ); }); it('should throw an error if a lon is not provided', async () => { @@ -133,7 +139,9 @@ describe('/map', () => { .get('/map/reverse-geocode?lat=75') .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[lon] Invalid input: expected number, received NaN'])); + expect(body).toEqual( + errorDto.validationError([{ path: ['lon'], message: 'Invalid input: expected number, received NaN' }]), + ); }); const reverseGeocodeTestCases = [ diff --git a/e2e/src/specs/server/api/oauth.e2e-spec.ts b/e2e/src/specs/server/api/oauth.e2e-spec.ts index 8851356c9e..4bf4f197b1 100644 --- a/e2e/src/specs/server/api/oauth.e2e-spec.ts +++ b/e2e/src/specs/server/api/oauth.e2e-spec.ts @@ -105,7 +105,11 @@ describe(`/oauth`, () => { it(`should throw an error if a redirect uri is not provided`, async () => { const { status, body } = await request(app).post('/oauth/authorize').send({}); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[redirectUri] Invalid input: expected string, received undefined'])); + expect(body).toEqual( + errorDto.validationError([ + { path: ['redirectUri'], message: 'Invalid input: expected string, received undefined' }, + ]), + ); }); it('should return a redirect uri', async () => { @@ -164,13 +168,17 @@ describe(`/oauth`, () => { it(`should throw an error if a url is not provided`, async () => { const { status, body } = await request(app).post('/oauth/callback').send({}); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[url] Invalid input: expected string, received undefined'])); + expect(body).toEqual( + errorDto.validationError([{ path: ['url'], message: 'Invalid input: expected string, received undefined' }]), + ); }); it(`should throw an error if the url is empty`, async () => { const { status, body } = await request(app).post('/oauth/callback').send({ url: '' }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[url] Too small: expected string to have >=1 characters'])); + expect(body).toEqual( + errorDto.validationError([{ path: ['url'], message: 'Too small: expected string to have >=1 characters' }]), + ); }); it(`should throw an error if the state is not provided`, async () => { @@ -375,7 +383,11 @@ describe(`/oauth`, () => { it(`should throw an error if the logout_token is not provided`, async () => { const { status, body } = await request(app).post('/oauth/backchannel-logout').send({}); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[logout_token] Invalid input: expected string, received undefined'])); + expect(body).toEqual( + errorDto.validationError([ + { path: ['logout_token'], message: 'Invalid input: expected string, received undefined' }, + ]), + ); }); it(`should throw an error if an invalid logout token is provided`, async () => { diff --git a/e2e/src/specs/server/api/shared-link.e2e-spec.ts b/e2e/src/specs/server/api/shared-link.e2e-spec.ts index 1d069d0f54..8cdf2dc03c 100644 --- a/e2e/src/specs/server/api/shared-link.e2e-spec.ts +++ b/e2e/src/specs/server/api/shared-link.e2e-spec.ts @@ -341,7 +341,9 @@ describe('/shared-links', () => { .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest()); + expect(body).toEqual( + errorDto.validationError([{ path: [], message: 'Invalid input: expected object, received undefined' }]), + ); }); it('should require an asset/album id', async () => { diff --git a/e2e/src/specs/server/api/stack.e2e-spec.ts b/e2e/src/specs/server/api/stack.e2e-spec.ts index 91dd0d2a8e..76bf514dc8 100644 --- a/e2e/src/specs/server/api/stack.e2e-spec.ts +++ b/e2e/src/specs/server/api/stack.e2e-spec.ts @@ -41,7 +41,9 @@ describe('/stacks', () => { .send({ assetIds: [asset.id] }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest()); + expect(body).toEqual( + errorDto.validationError([{ path: ['assetIds'], message: 'Too small: expected array to have >=2 items' }]), + ); }); it('should require a valid id', async () => { @@ -51,7 +53,12 @@ describe('/stacks', () => { .send({ assetIds: [uuidDto.invalid, uuidDto.invalid] }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest()); + expect(body).toEqual( + errorDto.validationError([ + { path: ['assetIds', 0], message: 'Invalid UUID' }, + { path: ['assetIds', 1], message: 'Invalid UUID' }, + ]), + ); }); it('should require access', async () => { diff --git a/e2e/src/specs/server/api/tag.e2e-spec.ts b/e2e/src/specs/server/api/tag.e2e-spec.ts index 7b5a2f16de..d303a1e98d 100644 --- a/e2e/src/specs/server/api/tag.e2e-spec.ts +++ b/e2e/src/specs/server/api/tag.e2e-spec.ts @@ -309,7 +309,7 @@ describe('/tags', () => { .get(`/tags/${uuidDto.invalid}`) .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID'])); + expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }])); }); it('should get tag details', async () => { @@ -427,7 +427,7 @@ describe('/tags', () => { .delete(`/tags/${uuidDto.invalid}`) .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID'])); + expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }])); }); it('should delete a tag', async () => { diff --git a/e2e/src/specs/server/api/user-admin.e2e-spec.ts b/e2e/src/specs/server/api/user-admin.e2e-spec.ts index 6751b21e84..df6fea84bc 100644 --- a/e2e/src/specs/server/api/user-admin.e2e-spec.ts +++ b/e2e/src/specs/server/api/user-admin.e2e-spec.ts @@ -108,14 +108,20 @@ describe('/admin/users', () => { expect(body).toEqual(errorDto.forbidden); }); - for (const key of ['password', 'email', 'name', 'quotaSizeInBytes', 'shouldChangePassword', 'notify']) { + for (const [key, message] of [ + ['password', 'Invalid input: expected string, received null'], + ['email', 'Invalid input: expected email, received object'], + ['name', 'Invalid input: expected string, received null'], + ['shouldChangePassword', 'Invalid input: expected boolean, received null'], + ['notify', 'Invalid input: expected boolean, received null'], + ] as const) { it(`should not allow null ${key}`, async () => { const { status, body } = await request(app) .post(`/admin/users`) .set('Authorization', `Bearer ${admin.accessToken}`) .send({ ...createUserDto.user1, [key]: null }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest()); + expect(body).toEqual(errorDto.validationError([{ path: [key], message }])); }); } @@ -153,14 +159,19 @@ describe('/admin/users', () => { expect(body).toEqual(errorDto.forbidden); }); - for (const key of ['password', 'email', 'name', 'shouldChangePassword']) { + for (const [key, message] of [ + ['password', 'Invalid input: expected string, received null'], + ['email', 'Invalid input: expected email, received object'], + ['name', 'Invalid input: expected string, received null'], + ['shouldChangePassword', 'Invalid input: expected boolean, received null'], + ] as const) { it(`should not allow null ${key}`, async () => { const { status, body } = await request(app) .put(`/admin/users/${uuidDto.notFound}`) .set('Authorization', `Bearer ${admin.accessToken}`) .send({ [key]: null }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest()); + expect(body).toEqual(errorDto.validationError([{ path: [key], message }])); }); } diff --git a/e2e/src/specs/server/api/user.e2e-spec.ts b/e2e/src/specs/server/api/user.e2e-spec.ts index 7623cb5a63..8a2197efde 100644 --- a/e2e/src/specs/server/api/user.e2e-spec.ts +++ b/e2e/src/specs/server/api/user.e2e-spec.ts @@ -179,7 +179,9 @@ describe('/users', () => { expect(status).toBe(400); expect(body).toEqual( - errorDto.badRequest(['[download.archiveSize] Invalid input: expected int, received number']), + errorDto.validationError([ + { path: ['download', 'archiveSize'], message: 'Invalid input: expected int, received number' }, + ]), ); }); @@ -207,7 +209,9 @@ describe('/users', () => { expect(status).toBe(400); expect(body).toEqual( - errorDto.badRequest(['[download.includeEmbeddedVideos] Invalid input: expected boolean, received number']), + errorDto.validationError([ + { path: ['download', 'includeEmbeddedVideos'], message: 'Invalid input: expected boolean, received number' }, + ]), ); }); diff --git a/open-api/typescript-sdk/src/fetch-errors.ts b/open-api/typescript-sdk/src/fetch-errors.ts index f21f0ed1c4..306710fb8b 100644 --- a/open-api/typescript-sdk/src/fetch-errors.ts +++ b/open-api/typescript-sdk/src/fetch-errors.ts @@ -1,9 +1,16 @@ import { HttpError } from '@oazapfts/runtime'; +export interface ApiValidationError { + code: string; + path: (string | number)[]; + message: string; +} + export interface ApiExceptionResponse { message: string; error?: string; statusCode: number; + errors?: ApiValidationError[]; } export interface ApiHttpError extends HttpError { diff --git a/server/src/controllers/activity.controller.spec.ts b/server/src/controllers/activity.controller.spec.ts index 7ac6e051f6..0b677b83fa 100644 --- a/server/src/controllers/activity.controller.spec.ts +++ b/server/src/controllers/activity.controller.spec.ts @@ -28,14 +28,16 @@ describe(ActivityController.name, () => { const { status, body } = await request(ctx.getHttpServer()).get('/activities'); expect(status).toEqual(400); expect(body).toEqual( - factory.responses.badRequest(['[albumId] Invalid input: expected string, received undefined']), + factory.responses.validationError([ + { path: ['albumId'], message: 'Invalid input: expected string, received undefined' }, + ]), ); }); it('should reject an invalid albumId', async () => { const { status, body } = await request(ctx.getHttpServer()).get('/activities').query({ albumId: '123' }); expect(status).toEqual(400); - expect(body).toEqual(factory.responses.badRequest(['[albumId] Invalid UUID'])); + expect(body).toEqual(factory.responses.validationError([{ path: ['albumId'], message: 'Invalid UUID' }])); }); it('should reject an invalid assetId', async () => { @@ -43,7 +45,7 @@ describe(ActivityController.name, () => { .get('/activities') .query({ albumId: factory.uuid(), assetId: '123' }); expect(status).toEqual(400); - expect(body).toEqual(factory.responses.badRequest(['[assetId] Invalid UUID'])); + expect(body).toEqual(factory.responses.validationError([{ path: ['assetId'], message: 'Invalid UUID' }])); }); }); @@ -58,7 +60,7 @@ describe(ActivityController.name, () => { .post('/activities') .send({ albumId: '123', type: 'like' }); expect(status).toEqual(400); - expect(body).toEqual(factory.responses.badRequest(['[albumId] Invalid UUID'])); + expect(body).toEqual(factory.responses.validationError([{ path: ['albumId'], message: 'Invalid UUID' }])); }); it('should require a comment when type is comment', async () => { @@ -66,7 +68,11 @@ describe(ActivityController.name, () => { .post('/activities') .send({ albumId: factory.uuid(), type: 'comment', comment: null }); expect(status).toEqual(400); - expect(body).toEqual(factory.responses.badRequest(['[comment] Invalid input: expected string, received null'])); + expect(body).toEqual( + factory.responses.validationError([ + { path: ['comment'], message: 'Invalid input: expected string, received null' }, + ]), + ); }); }); @@ -79,7 +85,7 @@ describe(ActivityController.name, () => { it('should require a valid uuid', async () => { const { status, body } = await request(ctx.getHttpServer()).delete(`/activities/123`); expect(status).toBe(400); - expect(body).toEqual(factory.responses.badRequest(['[id] Invalid UUID'])); + expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }])); }); }); }); diff --git a/server/src/controllers/album.controller.spec.ts b/server/src/controllers/album.controller.spec.ts index fadc5103eb..0c7a4eb09f 100644 --- a/server/src/controllers/album.controller.spec.ts +++ b/server/src/controllers/album.controller.spec.ts @@ -27,13 +27,17 @@ describe(AlbumController.name, () => { it('should reject an invalid shared param', async () => { const { status, body } = await request(ctx.getHttpServer()).get('/albums?shared=invalid'); expect(status).toEqual(400); - expect(body).toEqual(factory.responses.badRequest(['[shared] Invalid option: expected one of "true"|"false"'])); + expect(body).toEqual( + factory.responses.validationError([ + { path: ['shared'], message: 'Invalid option: expected one of "true"|"false"' }, + ]), + ); }); it('should reject an invalid assetId param', async () => { const { status, body } = await request(ctx.getHttpServer()).get('/albums?assetId=invalid'); expect(status).toEqual(400); - expect(body).toEqual(factory.responses.badRequest(['[assetId] Invalid UUID'])); + expect(body).toEqual(factory.responses.validationError([{ path: ['assetId'], message: 'Invalid UUID' }])); }); }); diff --git a/server/src/controllers/api-key.controller.spec.ts b/server/src/controllers/api-key.controller.spec.ts index 23a1f8b98c..91a7c43a2d 100644 --- a/server/src/controllers/api-key.controller.spec.ts +++ b/server/src/controllers/api-key.controller.spec.ts @@ -49,7 +49,7 @@ describe(ApiKeyController.name, () => { it('should require a valid uuid', async () => { const { status, body } = await request(ctx.getHttpServer()).get(`/api-keys/123`); expect(status).toBe(400); - expect(body).toEqual(factory.responses.badRequest(['[id] Invalid UUID'])); + expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }])); }); }); @@ -64,7 +64,7 @@ describe(ApiKeyController.name, () => { .put(`/api-keys/123`) .send({ name: 'new name', permissions: [Permission.All] }); expect(status).toBe(400); - expect(body).toEqual(factory.responses.badRequest(['[id] Invalid UUID'])); + expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }])); }); it('should allow updating just the name', async () => { @@ -84,7 +84,7 @@ describe(ApiKeyController.name, () => { it('should require a valid uuid', async () => { const { status, body } = await request(ctx.getHttpServer()).delete(`/api-keys/123`); expect(status).toBe(400); - expect(body).toEqual(factory.responses.badRequest(['[id] Invalid UUID'])); + expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }])); }); }); }); diff --git a/server/src/controllers/asset-media.controller.spec.ts b/server/src/controllers/asset-media.controller.spec.ts index 6a328b1f6d..056c3d4df7 100644 --- a/server/src/controllers/asset-media.controller.spec.ts +++ b/server/src/controllers/asset-media.controller.spec.ts @@ -80,7 +80,9 @@ describe(AssetMediaController.name, () => { expect(status).toBe(400); expect(body).toEqual( - factory.responses.badRequest(['[metadata] Invalid input: expected JSON string, received string']), + factory.responses.validationError([ + { path: ['metadata'], message: 'Invalid input: expected JSON string, received string' }, + ]), ); }); @@ -91,8 +93,8 @@ describe(AssetMediaController.name, () => { .field({ ...makeUploadDto({ omit: 'fileCreatedAt' }) }); expect(status).toBe(400); expect(body).toEqual( - factory.responses.badRequest([ - '[fileCreatedAt] Invalid input: expected ISO 8601 datetime string, received undefined', + factory.responses.validationError([ + { path: ['fileCreatedAt'], message: 'Invalid input: expected ISO 8601 datetime string, received undefined' }, ]), ); }); @@ -104,8 +106,8 @@ describe(AssetMediaController.name, () => { .field(makeUploadDto({ omit: 'fileModifiedAt' })); expect(status).toBe(400); expect(body).toEqual( - factory.responses.badRequest([ - '[fileModifiedAt] Invalid input: expected ISO 8601 datetime string, received undefined', + factory.responses.validationError([ + { path: ['fileModifiedAt'], message: 'Invalid input: expected ISO 8601 datetime string, received undefined' }, ]), ); }); @@ -117,7 +119,9 @@ describe(AssetMediaController.name, () => { .field({ ...makeUploadDto(), isFavorite: 'not-a-boolean' }); expect(status).toBe(400); expect(body).toEqual( - factory.responses.badRequest(['[isFavorite] Invalid option: expected one of "true"|"false"']), + factory.responses.validationError([ + { path: ['isFavorite'], message: 'Invalid option: expected one of "true"|"false"' }, + ]), ); }); @@ -128,7 +132,9 @@ describe(AssetMediaController.name, () => { .field({ ...makeUploadDto(), visibility: 'not-an-option' }); expect(status).toBe(400); expect(body).toEqual( - factory.responses.badRequest([expect.stringContaining('[visibility] Invalid option: expected one of')]), + factory.responses.validationError([ + { path: ['visibility'], message: expect.stringContaining('Invalid option: expected one of') }, + ]), ); }); diff --git a/server/src/controllers/asset.controller.spec.ts b/server/src/controllers/asset.controller.spec.ts index 3c01e3d0a9..acdcb84403 100644 --- a/server/src/controllers/asset.controller.spec.ts +++ b/server/src/controllers/asset.controller.spec.ts @@ -31,7 +31,7 @@ describe(AssetController.name, () => { .send({ ids: ['123'] }); expect(status).toBe(400); - expect(body).toEqual(factory.responses.badRequest(['[ids.0] Invalid UUID'])); + expect(body).toEqual(factory.responses.validationError([{ path: ['ids', 0], message: 'Invalid UUID' }])); }); it('should require duplicateId to be a string', async () => { @@ -42,7 +42,9 @@ describe(AssetController.name, () => { expect(status).toBe(400); expect(body).toEqual( - factory.responses.badRequest(['[duplicateId] Invalid input: expected string, received boolean']), + factory.responses.validationError([ + { path: ['duplicateId'], message: 'Invalid input: expected string, received boolean' }, + ]), ); }); @@ -70,7 +72,7 @@ describe(AssetController.name, () => { .send({ ids: ['123'] }); expect(status).toBe(400); - expect(body).toEqual(factory.responses.badRequest(['[ids.0] Invalid UUID'])); + expect(body).toEqual(factory.responses.validationError([{ path: ['ids', 0], message: 'Invalid UUID' }])); }); }); @@ -83,7 +85,7 @@ describe(AssetController.name, () => { it('should require a valid id', async () => { const { status, body } = await request(ctx.getHttpServer()).get(`/assets/123`); expect(status).toBe(400); - expect(body).toEqual(factory.responses.badRequest(['[id] Invalid UUID'])); + expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }])); }); }); @@ -97,12 +99,10 @@ describe(AssetController.name, () => { const { status, body } = await request(ctx.getHttpServer()).put('/assets/copy').send({}); expect(status).toBe(400); expect(body).toEqual( - factory.responses.badRequest( - expect.arrayContaining([ - '[sourceId] Invalid input: expected string, received undefined', - '[targetId] Invalid input: expected string, received undefined', - ]), - ), + factory.responses.validationError([ + { path: ['sourceId'], message: 'Invalid input: expected string, received undefined' }, + { path: ['targetId'], message: 'Invalid input: expected string, received undefined' }, + ]), ); }); @@ -125,7 +125,9 @@ describe(AssetController.name, () => { .put('/assets/metadata') .send({ items: [{ assetId: '123', key: 'test', value: {} }] }); expect(status).toBe(400); - expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['[items.0.assetId] Invalid UUID']))); + expect(body).toEqual( + factory.responses.validationError([{ path: ['items', 0, 'assetId'], message: 'Invalid UUID' }]), + ); }); it('should require a key', async () => { @@ -134,9 +136,9 @@ describe(AssetController.name, () => { .send({ items: [{ assetId: factory.uuid(), value: {} }] }); expect(status).toBe(400); expect(body).toEqual( - factory.responses.badRequest( - expect.arrayContaining(['[items.0.key] Invalid input: expected string, received undefined']), - ), + factory.responses.validationError([ + { path: ['items', 0, 'key'], message: 'Invalid input: expected string, received undefined' }, + ]), ); }); @@ -159,7 +161,9 @@ describe(AssetController.name, () => { .delete('/assets/metadata') .send({ items: [{ assetId: '123', key: 'test' }] }); expect(status).toBe(400); - expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['[items.0.assetId] Invalid UUID']))); + expect(body).toEqual( + factory.responses.validationError([{ path: ['items', 0, 'assetId'], message: 'Invalid UUID' }]), + ); }); it('should require a key', async () => { @@ -168,9 +172,9 @@ describe(AssetController.name, () => { .send({ items: [{ assetId: factory.uuid() }] }); expect(status).toBe(400); expect(body).toEqual( - factory.responses.badRequest( - expect.arrayContaining(['[items.0.key] Invalid input: expected string, received undefined']), - ), + factory.responses.validationError([ + { path: ['items', 0, 'key'], message: 'Invalid input: expected string, received undefined' }, + ]), ); }); @@ -191,33 +195,56 @@ describe(AssetController.name, () => { it('should require a valid id', async () => { const { status, body } = await request(ctx.getHttpServer()).put(`/assets/123`); expect(status).toBe(400); - expect(body).toEqual(factory.responses.badRequest(['Invalid input: expected object, received undefined'])); + expect(body).toEqual( + factory.responses.validationError([ + { path: [], message: 'Invalid input: expected object, received undefined' }, + ]), + ); }); it('should reject invalid gps coordinates', async () => { - for (const test of [ - { latitude: 12 }, - { longitude: 12 }, - { latitude: 12, longitude: 'abc' }, - { latitude: 'abc', longitude: 12 }, - { latitude: null, longitude: 12 }, - { latitude: 12, longitude: null }, - { latitude: 91, longitude: 12 }, - { latitude: -91, longitude: 12 }, - { latitude: 12, longitude: -181 }, - { latitude: 12, longitude: 181 }, - ]) { + for (const [test, errors] of [ + [{ latitude: 12 }, [{ path: [], message: 'Latitude and longitude must be provided together' }]], + [{ longitude: 12 }, [{ path: [], message: 'Latitude and longitude must be provided together' }]], + [ + { latitude: 12, longitude: 'abc' }, + [{ path: ['longitude'], message: 'Invalid input: expected number, received string' }], + ], + [ + { latitude: 'abc', longitude: 12 }, + [{ path: ['latitude'], message: 'Invalid input: expected number, received string' }], + ], + [ + { latitude: null, longitude: 12 }, + [{ path: ['latitude'], message: 'Invalid input: expected number, received null' }], + ], + [ + { latitude: 12, longitude: null }, + [{ path: ['longitude'], message: 'Invalid input: expected number, received null' }], + ], + [{ latitude: 91, longitude: 12 }, [{ path: ['latitude'], message: 'Too big: expected number to be <=90' }]], + [{ latitude: -91, longitude: 12 }, [{ path: ['latitude'], message: 'Too small: expected number to be >=-90' }]], + [ + { latitude: 12, longitude: -181 }, + [{ path: ['longitude'], message: 'Too small: expected number to be >=-180' }], + ], + [{ latitude: 12, longitude: 181 }, [{ path: ['longitude'], message: 'Too big: expected number to be <=180' }]], + ] as const) { const { status, body } = await request(ctx.getHttpServer()).put(`/assets/${factory.uuid()}`).send(test); expect(status).toBe(400); - expect(body).toEqual(factory.responses.badRequest()); + expect(body).toEqual(factory.responses.validationError(errors)); } }); it('should reject invalid rating', async () => { - for (const test of [{ rating: 7 }, { rating: 3.5 }, { rating: -2 }]) { + for (const [test, errors] of [ + [{ rating: 7 }, [{ path: ['rating'], message: 'Too big: expected number to be <=5' }]], + [{ rating: 3.5 }, [{ path: ['rating'], message: 'Invalid input: expected int, received number' }]], + [{ rating: -2 }, [{ path: ['rating'], message: 'Too small: expected number to be >=-1' }]], + ] as const) { const { status, body } = await request(ctx.getHttpServer()).put(`/assets/${factory.uuid()}`).send(test); expect(status).toBe(400); - expect(body).toEqual(factory.responses.badRequest()); + expect(body).toEqual(factory.responses.validationError(errors)); } }); @@ -261,13 +288,17 @@ describe(AssetController.name, () => { it('should require a valid id', async () => { const { status, body } = await request(ctx.getHttpServer()).put(`/assets/123/metadata`).send({ items: [] }); expect(status).toBe(400); - expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['[id] Invalid UUID']))); + expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }])); }); it('should require items to be an array', async () => { const { status, body } = await request(ctx.getHttpServer()).put(`/assets/${factory.uuid()}/metadata`).send({}); expect(status).toBe(400); - expect(body).toEqual(factory.responses.badRequest(['[items] Invalid input: expected array, received undefined'])); + expect(body).toEqual( + factory.responses.validationError([ + { path: ['items'], message: 'Invalid input: expected array, received undefined' }, + ]), + ); }); it('should require each item to have a valid key', async () => { @@ -276,7 +307,9 @@ describe(AssetController.name, () => { .send({ items: [{ value: { some: 'value' } }] }); expect(status).toBe(400); expect(body).toEqual( - factory.responses.badRequest(['[items.0.key] Invalid input: expected string, received undefined']), + factory.responses.validationError([ + { path: ['items', 0, 'key'], message: 'Invalid input: expected string, received undefined' }, + ]), ); }); @@ -286,9 +319,9 @@ describe(AssetController.name, () => { .send({ items: [{ key: 'mobile-app', value: null }] }); expect(status).toBe(400); expect(body).toEqual( - factory.responses.badRequest( - expect.arrayContaining(['[items.0.value] Invalid input: expected record, received null']), - ), + factory.responses.validationError([ + { path: ['items', 0, 'value'], message: 'Invalid input: expected record, received null' }, + ]), ); }); @@ -326,7 +359,7 @@ describe(AssetController.name, () => { it('should require a valid id', async () => { const { status, body } = await request(ctx.getHttpServer()).get(`/assets/123/metadata/mobile-app`); expect(status).toBe(400); - expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['[id] Invalid UUID']))); + expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }])); }); }); @@ -376,7 +409,7 @@ describe(AssetController.name, () => { }); expect(status).toBe(400); - expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['[id] Invalid UUID']))); + expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }])); }); it('should check the action and parameters discriminator', async () => { @@ -398,13 +431,12 @@ describe(AssetController.name, () => { expect(status).toBe(400); expect(body).toEqual( - factory.responses.badRequest( - expect.arrayContaining([ - expect.stringContaining( - "[edits.0.parameters] Invalid parameters for action 'rotate', expecting keys: angle", - ), - ]), - ), + factory.responses.validationError([ + { + path: ['edits', 0, 'parameters'], + message: expect.stringContaining("Invalid parameters for action 'rotate', expecting keys: angle"), + }, + ]), ); }); @@ -413,7 +445,11 @@ describe(AssetController.name, () => { .put(`/assets/${factory.uuid()}/edits`) .send({ edits: [] }); expect(status).toBe(400); - expect(body).toEqual(factory.responses.badRequest(['[edits] Too small: expected array to have >=1 items'])); + expect(body).toEqual( + factory.responses.validationError([ + { path: ['edits'], message: 'Too small: expected array to have >=1 items' }, + ]), + ); }); }); @@ -426,7 +462,7 @@ describe(AssetController.name, () => { it('should require a valid id', async () => { const { status, body } = await request(ctx.getHttpServer()).delete(`/assets/123/metadata/mobile-app`); expect(status).toBe(400); - expect(body).toEqual(factory.responses.badRequest(['[id] Invalid UUID'])); + expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }])); }); }); }); diff --git a/server/src/controllers/auth.controller.spec.ts b/server/src/controllers/auth.controller.spec.ts index a61397e75c..d105dd90b9 100644 --- a/server/src/controllers/auth.controller.spec.ts +++ b/server/src/controllers/auth.controller.spec.ts @@ -28,19 +28,27 @@ describe(AuthController.name, () => { it('should require an email address', async () => { const { status, body } = await request(ctx.getHttpServer()).post('/auth/admin-sign-up').send({ name, password }); expect(status).toEqual(400); - expect(body).toEqual(errorDto.badRequest()); + expect(body).toEqual( + errorDto.validationError([{ path: ['email'], message: 'Invalid input: expected email, received undefined' }]), + ); }); it('should require a password', async () => { const { status, body } = await request(ctx.getHttpServer()).post('/auth/admin-sign-up').send({ name, email }); expect(status).toEqual(400); - expect(body).toEqual(errorDto.badRequest()); + expect(body).toEqual( + errorDto.validationError([ + { path: ['password'], message: 'Invalid input: expected string, received undefined' }, + ]), + ); }); it('should require a name', async () => { const { status, body } = await request(ctx.getHttpServer()).post('/auth/admin-sign-up').send({ email, password }); expect(status).toEqual(400); - expect(body).toEqual(errorDto.badRequest()); + expect(body).toEqual( + errorDto.validationError([{ path: ['name'], message: 'Invalid input: expected string, received undefined' }]), + ); }); it('should require a valid email', async () => { @@ -48,7 +56,9 @@ describe(AuthController.name, () => { .post('/auth/admin-sign-up') .send({ name, email: 'immich', password }); expect(status).toEqual(400); - expect(body).toEqual(errorDto.badRequest()); + expect(body).toEqual( + errorDto.validationError([{ path: ['email'], message: 'Invalid input: expected email, received string' }]), + ); }); it('should transform email to lower case', async () => { @@ -73,9 +83,9 @@ describe(AuthController.name, () => { const { status, body } = await request(ctx.getHttpServer()).post('/auth/login').send({ name: 'admin' }); expect(status).toBe(400); expect(body).toEqual( - errorDto.badRequest([ - '[email] Invalid input: expected email, received undefined', - '[password] Invalid input: expected string, received undefined', + errorDto.validationError([ + { path: ['email'], message: 'Invalid input: expected email, received undefined' }, + { path: ['password'], message: 'Invalid input: expected string, received undefined' }, ]), ); }); @@ -85,7 +95,9 @@ describe(AuthController.name, () => { .post('/auth/login') .send({ name: 'admin', email: null, password: 'password' }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[email] Invalid input: expected email, received object'])); + expect(body).toEqual( + errorDto.validationError([{ path: ['email'], message: 'Invalid input: expected email, received object' }]), + ); }); it(`should not allow null password`, async () => { @@ -93,7 +105,9 @@ describe(AuthController.name, () => { .post('/auth/login') .send({ name: 'admin', email: 'admin@immich.cloud', password: null }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[password] Invalid input: expected string, received null'])); + expect(body).toEqual( + errorDto.validationError([{ path: ['password'], message: 'Invalid input: expected string, received null' }]), + ); }); it('should reject an invalid email', async () => { @@ -104,7 +118,9 @@ describe(AuthController.name, () => { .send({ name: 'admin', email: [], password: 'password' }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[email] Invalid input: expected email, received object'])); + expect(body).toEqual( + errorDto.validationError([{ path: ['email'], message: 'Invalid input: expected email, received object' }]), + ); }); it('should transform the email to all lowercase', async () => { @@ -195,19 +211,31 @@ describe(AuthController.name, () => { it('should reject 5 digits', async () => { const { status, body } = await request(ctx.getHttpServer()).post('/auth/pin-code').send({ pinCode: '12345' }); expect(status).toEqual(400); - expect(body).toEqual(errorDto.badRequest([String.raw`[pinCode] Invalid string: must match pattern /^\d{6}$/`])); + expect(body).toEqual( + errorDto.validationError([ + { path: ['pinCode'], message: String.raw`Invalid string: must match pattern /^\d{6}$/` }, + ]), + ); }); it('should reject 7 digits', async () => { const { status, body } = await request(ctx.getHttpServer()).post('/auth/pin-code').send({ pinCode: '1234567' }); expect(status).toEqual(400); - expect(body).toEqual(errorDto.badRequest([String.raw`[pinCode] Invalid string: must match pattern /^\d{6}$/`])); + expect(body).toEqual( + errorDto.validationError([ + { path: ['pinCode'], message: String.raw`Invalid string: must match pattern /^\d{6}$/` }, + ]), + ); }); it('should reject non-numbers', async () => { const { status, body } = await request(ctx.getHttpServer()).post('/auth/pin-code').send({ pinCode: 'A12345' }); expect(status).toEqual(400); - expect(body).toEqual(errorDto.badRequest([String.raw`[pinCode] Invalid string: must match pattern /^\d{6}$/`])); + expect(body).toEqual( + errorDto.validationError([ + { path: ['pinCode'], message: String.raw`Invalid string: must match pattern /^\d{6}$/` }, + ]), + ); }); }); diff --git a/server/src/controllers/duplicate.controller.spec.ts b/server/src/controllers/duplicate.controller.spec.ts index 3e11b628e3..7bbafb4665 100644 --- a/server/src/controllers/duplicate.controller.spec.ts +++ b/server/src/controllers/duplicate.controller.spec.ts @@ -41,7 +41,7 @@ describe(DuplicateController.name, () => { it('should require a valid uuid', async () => { const { status, body } = await request(ctx.getHttpServer()).delete(`/duplicates/123`); expect(status).toBe(400); - expect(body).toEqual(factory.responses.badRequest(['[id] Invalid UUID'])); + expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }])); }); }); }); diff --git a/server/src/controllers/maintenance.controller.spec.ts b/server/src/controllers/maintenance.controller.spec.ts index 07c0149463..630bb7c8b8 100644 --- a/server/src/controllers/maintenance.controller.spec.ts +++ b/server/src/controllers/maintenance.controller.spec.ts @@ -31,7 +31,9 @@ describe(MaintenanceController.name, () => { }); expect(status).toBe(400); expect(body).toEqual( - errorDto.badRequest(['[restoreBackupFilename] Backup filename is required when action is restore_database']), + errorDto.validationError([ + { path: ['restoreBackupFilename'], message: 'Backup filename is required when action is restore_database' }, + ]), ); expect(ctx.authenticate).toHaveBeenCalled(); }); diff --git a/server/src/controllers/memory.controller.spec.ts b/server/src/controllers/memory.controller.spec.ts index 6a84edce45..64d225f155 100644 --- a/server/src/controllers/memory.controller.spec.ts +++ b/server/src/controllers/memory.controller.spec.ts @@ -47,7 +47,11 @@ describe(MemoryController.name, () => { }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[data.year] Invalid input: expected number, received undefined'])); + expect(body).toEqual( + errorDto.validationError([ + { path: ['data', 'year'], message: 'Invalid input: expected number, received undefined' }, + ]), + ); }); it('should accept showAt and hideAt', async () => { @@ -81,7 +85,7 @@ describe(MemoryController.name, () => { it('should require a valid id', async () => { const { status, body } = await request(ctx.getHttpServer()).get(`/memories/invalid`); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID'])); + expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }])); }); }); @@ -94,13 +98,15 @@ describe(MemoryController.name, () => { it('should require a valid id', async () => { const { status, body } = await request(ctx.getHttpServer()).put(`/memories/invalid`); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['Invalid input: expected object, received undefined'])); + expect(body).toEqual( + errorDto.validationError([{ path: [], message: 'Invalid input: expected object, received undefined' }]), + ); }); it('should require at least one field', async () => { const { status, body } = await request(ctx.getHttpServer()).put(`/memories/${factory.uuid()}`).send({}); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['At least one field must be provided'])); + expect(body).toEqual(errorDto.validationError([{ path: [], message: 'At least one field must be provided' }])); }); }); @@ -120,7 +126,7 @@ describe(MemoryController.name, () => { it('should require a valid id', async () => { const { status, body } = await request(ctx.getHttpServer()).put(`/memories/invalid/assets`).send({ ids: [] }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID'])); + expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }])); }); it('should require a valid asset id', async () => { @@ -128,7 +134,7 @@ describe(MemoryController.name, () => { .put(`/memories/${factory.uuid()}/assets`) .send({ ids: ['invalid'] }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[ids.0] Invalid UUID'])); + expect(body).toEqual(errorDto.validationError([{ path: ['ids', 0], message: 'Invalid UUID' }])); }); }); @@ -141,7 +147,7 @@ describe(MemoryController.name, () => { it('should require a valid id', async () => { const { status, body } = await request(ctx.getHttpServer()).delete(`/memories/invalid/assets`); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID'])); + expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }])); }); it('should require a valid asset id', async () => { @@ -149,7 +155,7 @@ describe(MemoryController.name, () => { .delete(`/memories/${factory.uuid()}/assets`) .send({ ids: ['invalid'] }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[ids.0] Invalid UUID'])); + expect(body).toEqual(errorDto.validationError([{ path: ['ids', 0], message: 'Invalid UUID' }])); }); }); }); diff --git a/server/src/controllers/notification.controller.spec.ts b/server/src/controllers/notification.controller.spec.ts index e9886ebb07..1759e13404 100644 --- a/server/src/controllers/notification.controller.spec.ts +++ b/server/src/controllers/notification.controller.spec.ts @@ -31,7 +31,11 @@ describe(NotificationController.name, () => { .query({ level: 'invalid' }) .set('Authorization', `Bearer token`); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest([expect.stringContaining('[level] Invalid option: expected one of')])); + expect(body).toEqual( + errorDto.validationError([ + { path: ['level'], message: expect.stringContaining('Invalid option: expected one of') }, + ]), + ); }); }); @@ -45,7 +49,9 @@ describe(NotificationController.name, () => { it('should require a list', async () => { const { status, body } = await request(ctx.getHttpServer()).put(`/notifications`).send({ ids: true }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[ids] Invalid input: expected array, received boolean'])); + expect(body).toEqual( + errorDto.validationError([{ path: ['ids'], message: 'Invalid input: expected array, received boolean' }]), + ); }); it('should require uuids', async () => { @@ -53,7 +59,9 @@ describe(NotificationController.name, () => { .put(`/notifications`) .send({ ids: [true] }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[ids.0] Invalid input: expected string, received boolean'])); + expect(body).toEqual( + errorDto.validationError([{ path: ['ids', 0], message: 'Invalid input: expected string, received boolean' }]), + ); }); it('should accept valid uuids', async () => { @@ -75,7 +83,7 @@ describe(NotificationController.name, () => { it('should require a valid uuid', async () => { const { status, body } = await request(ctx.getHttpServer()).get(`/notifications/123`); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID'])); + expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }])); }); }); diff --git a/server/src/controllers/partner.controller.spec.ts b/server/src/controllers/partner.controller.spec.ts index 0661e9121b..d6541411b8 100644 --- a/server/src/controllers/partner.controller.spec.ts +++ b/server/src/controllers/partner.controller.spec.ts @@ -33,7 +33,9 @@ describe(PartnerController.name, () => { const { status, body } = await request(ctx.getHttpServer()).get(`/partners`).set('Authorization', `Bearer token`); expect(status).toBe(400); expect(body).toEqual( - errorDto.badRequest([expect.stringContaining('[direction] Invalid option: expected one of')]), + errorDto.validationError([ + { path: ['direction'], message: expect.stringContaining('Invalid option: expected one of') }, + ]), ); }); @@ -44,7 +46,9 @@ describe(PartnerController.name, () => { .set('Authorization', `Bearer token`); expect(status).toBe(400); expect(body).toEqual( - errorDto.badRequest([expect.stringContaining('[direction] Invalid option: expected one of')]), + errorDto.validationError([ + { path: ['direction'], message: expect.stringContaining('Invalid option: expected one of') }, + ]), ); }); }); @@ -61,7 +65,7 @@ describe(PartnerController.name, () => { .send({ sharedWithId: 'invalid' }) .set('Authorization', `Bearer token`); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[sharedWithId] Invalid UUID'])); + expect(body).toEqual(errorDto.validationError([{ path: ['sharedWithId'], message: 'Invalid UUID' }])); }); }); @@ -77,7 +81,7 @@ describe(PartnerController.name, () => { .send({ inTimeline: true }) .set('Authorization', `Bearer token`); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID'])); + expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }])); }); }); @@ -92,7 +96,7 @@ describe(PartnerController.name, () => { .delete(`/partners/invalid`) .set('Authorization', `Bearer token`); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID'])); + expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }])); }); }); }); diff --git a/server/src/controllers/person.controller.spec.ts b/server/src/controllers/person.controller.spec.ts index c6c0a1c91f..cf3a5e56b0 100644 --- a/server/src/controllers/person.controller.spec.ts +++ b/server/src/controllers/person.controller.spec.ts @@ -35,7 +35,7 @@ describe(PersonController.name, () => { .query({ closestPersonId: 'invalid' }) .set('Authorization', `Bearer token`); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[closestPersonId] Invalid UUID'])); + expect(body).toEqual(errorDto.validationError([{ path: ['closestPersonId'], message: 'Invalid UUID' }])); }); it(`should require closestAssetId to be a uuid`, async () => { @@ -44,7 +44,7 @@ describe(PersonController.name, () => { .query({ closestAssetId: 'invalid' }) .set('Authorization', `Bearer token`); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[closestAssetId] Invalid UUID'])); + expect(body).toEqual(errorDto.validationError([{ path: ['closestAssetId'], message: 'Invalid UUID' }])); }); }); @@ -76,7 +76,7 @@ describe(PersonController.name, () => { .delete('/people') .send({ ids: ['invalid'] }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[ids.0] Invalid UUID'])); + expect(body).toEqual(errorDto.validationError([{ path: ['ids', 0], message: 'Invalid UUID' }])); }); it('should respond with 204', async () => { @@ -104,7 +104,9 @@ describe(PersonController.name, () => { it('should require a valid uuid', async () => { const { status, body } = await request(ctx.getHttpServer()).put(`/people/123`); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['Invalid input: expected object, received undefined'])); + expect(body).toEqual( + errorDto.validationError([{ path: [], message: 'Invalid input: expected object, received undefined' }]), + ); }); it(`should not allow a null name`, async () => { @@ -113,7 +115,9 @@ describe(PersonController.name, () => { .send({ name: null }) .set('Authorization', `Bearer token`); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[name] Invalid input: expected string, received null'])); + expect(body).toEqual( + errorDto.validationError([{ path: ['name'], message: 'Invalid input: expected string, received null' }]), + ); }); it(`should require featureFaceAssetId to be a uuid`, async () => { @@ -122,7 +126,7 @@ describe(PersonController.name, () => { .send({ featureFaceAssetId: 'invalid' }) .set('Authorization', `Bearer token`); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[featureFaceAssetId] Invalid UUID'])); + expect(body).toEqual(errorDto.validationError([{ path: ['featureFaceAssetId'], message: 'Invalid UUID' }])); }); it(`should require isFavorite to be a boolean`, async () => { @@ -131,7 +135,11 @@ describe(PersonController.name, () => { .send({ isFavorite: 'invalid' }) .set('Authorization', `Bearer token`); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[isFavorite] Invalid input: expected boolean, received string'])); + expect(body).toEqual( + errorDto.validationError([ + { path: ['isFavorite'], message: 'Invalid input: expected boolean, received string' }, + ]), + ); }); it(`should require isHidden to be a boolean`, async () => { @@ -140,7 +148,9 @@ describe(PersonController.name, () => { .send({ isHidden: 'invalid' }) .set('Authorization', `Bearer token`); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[isHidden] Invalid input: expected boolean, received string'])); + expect(body).toEqual( + errorDto.validationError([{ path: ['isHidden'], message: 'Invalid input: expected boolean, received string' }]), + ); }); it('should map an empty birthDate to null', async () => { @@ -154,7 +164,11 @@ describe(PersonController.name, () => { .put(`/people/${factory.uuid()}`) .send({ birthDate: false }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[birthDate] Invalid input: expected string, received boolean'])); + expect(body).toEqual( + errorDto.validationError([ + { path: ['birthDate'], message: 'Invalid input: expected string, received boolean' }, + ]), + ); }); it('should not accept an invalid birth date (number)', async () => { @@ -162,7 +176,9 @@ describe(PersonController.name, () => { .put(`/people/${factory.uuid()}`) .send({ birthDate: 123_456 }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[birthDate] Invalid input: expected string, received number'])); + expect(body).toEqual( + errorDto.validationError([{ path: ['birthDate'], message: 'Invalid input: expected string, received number' }]), + ); }); it('should not accept a birth date in the future)', async () => { @@ -170,7 +186,9 @@ describe(PersonController.name, () => { .put(`/people/${factory.uuid()}`) .send({ birthDate: '9999-01-01' }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[birthDate] Birth date cannot be in the future'])); + expect(body).toEqual( + errorDto.validationError([{ path: ['birthDate'], message: 'Birth date cannot be in the future' }]), + ); }); }); @@ -183,7 +201,7 @@ describe(PersonController.name, () => { it('should require a valid uuid', async () => { const { status, body } = await request(ctx.getHttpServer()).delete(`/people/invalid`); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID'])); + expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }])); }); it('should respond with 204', async () => { diff --git a/server/src/controllers/search.controller.spec.ts b/server/src/controllers/search.controller.spec.ts index 81cdb5ef6a..a1fed4c7ae 100644 --- a/server/src/controllers/search.controller.spec.ts +++ b/server/src/controllers/search.controller.spec.ts @@ -27,31 +27,41 @@ describe(SearchController.name, () => { it('should reject page as a string', async () => { const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ page: 'abc' }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[page] Invalid input: expected number, received string'])); + expect(body).toEqual( + errorDto.validationError([{ path: ['page'], message: 'Invalid input: expected number, received string' }]), + ); }); it('should reject page as a negative number', async () => { const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ page: -10 }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[page] Too small: expected number to be >=1'])); + expect(body).toEqual( + errorDto.validationError([{ path: ['page'], message: 'Too small: expected number to be >=1' }]), + ); }); it('should reject page as 0', async () => { const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ page: 0 }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[page] Too small: expected number to be >=1'])); + expect(body).toEqual( + errorDto.validationError([{ path: ['page'], message: 'Too small: expected number to be >=1' }]), + ); }); it('should reject size as a string', async () => { const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ size: 'abc' }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[size] Invalid input: expected number, received string'])); + expect(body).toEqual( + errorDto.validationError([{ path: ['size'], message: 'Invalid input: expected number, received string' }]), + ); }); it('should reject an invalid size', async () => { const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ size: -1 }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[size] Too small: expected number to be >=1'])); + expect(body).toEqual( + errorDto.validationError([{ path: ['size'], message: 'Too small: expected number to be >=1' }]), + ); }); it('should reject an visibility as not an enum', async () => { @@ -60,7 +70,9 @@ describe(SearchController.name, () => { .send({ visibility: 'immich' }); expect(status).toBe(400); expect(body).toEqual( - errorDto.badRequest([expect.stringContaining('[visibility] Invalid option: expected one of')]), + errorDto.validationError([ + { path: ['visibility'], message: expect.stringContaining('Invalid option: expected one of') }, + ]), ); }); @@ -69,7 +81,11 @@ describe(SearchController.name, () => { .post('/search/metadata') .send({ isFavorite: 'immich' }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[isFavorite] Invalid input: expected boolean, received string'])); + expect(body).toEqual( + errorDto.validationError([ + { path: ['isFavorite'], message: 'Invalid input: expected boolean, received string' }, + ]), + ); }); it('should reject an isEncoded as not a boolean', async () => { @@ -77,7 +93,11 @@ describe(SearchController.name, () => { .post('/search/metadata') .send({ isEncoded: 'immich' }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[isEncoded] Invalid input: expected boolean, received string'])); + expect(body).toEqual( + errorDto.validationError([ + { path: ['isEncoded'], message: 'Invalid input: expected boolean, received string' }, + ]), + ); }); it('should reject an isOffline as not a boolean', async () => { @@ -85,13 +105,19 @@ describe(SearchController.name, () => { .post('/search/metadata') .send({ isOffline: 'immich' }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[isOffline] Invalid input: expected boolean, received string'])); + expect(body).toEqual( + errorDto.validationError([ + { path: ['isOffline'], message: 'Invalid input: expected boolean, received string' }, + ]), + ); }); it('should reject an isMotion as not a boolean', async () => { const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ isMotion: 'immich' }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[isMotion] Invalid input: expected boolean, received string'])); + expect(body).toEqual( + errorDto.validationError([{ path: ['isMotion'], message: 'Invalid input: expected boolean, received string' }]), + ); }); describe('POST /search/random', () => { @@ -105,7 +131,11 @@ describe(SearchController.name, () => { .post('/search/random') .send({ withStacked: 'immich' }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[withStacked] Invalid input: expected boolean, received string'])); + expect(body).toEqual( + errorDto.validationError([ + { path: ['withStacked'], message: 'Invalid input: expected boolean, received string' }, + ]), + ); }); it('should reject if withPeople is not a boolean', async () => { @@ -113,7 +143,11 @@ describe(SearchController.name, () => { .post('/search/random') .send({ withPeople: 'immich' }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[withPeople] Invalid input: expected boolean, received string'])); + expect(body).toEqual( + errorDto.validationError([ + { path: ['withPeople'], message: 'Invalid input: expected boolean, received string' }, + ]), + ); }); }); @@ -140,7 +174,9 @@ describe(SearchController.name, () => { it('should require a name', async () => { const { status, body } = await request(ctx.getHttpServer()).get('/search/person').send({}); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[name] Invalid input: expected string, received undefined'])); + expect(body).toEqual( + errorDto.validationError([{ path: ['name'], message: 'Invalid input: expected string, received undefined' }]), + ); }); }); @@ -153,7 +189,9 @@ describe(SearchController.name, () => { it('should require a name', async () => { const { status, body } = await request(ctx.getHttpServer()).get('/search/places').send({}); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[name] Invalid input: expected string, received undefined'])); + expect(body).toEqual( + errorDto.validationError([{ path: ['name'], message: 'Invalid input: expected string, received undefined' }]), + ); }); }); @@ -173,7 +211,11 @@ describe(SearchController.name, () => { it('should require a type', async () => { const { status, body } = await request(ctx.getHttpServer()).get('/search/suggestions').send({}); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest([expect.stringContaining('[type] Invalid option: expected one of')])); + expect(body).toEqual( + errorDto.validationError([ + { path: ['type'], message: expect.stringContaining('Invalid option: expected one of') }, + ]), + ); }); }); }); diff --git a/server/src/controllers/sync.controller.spec.ts b/server/src/controllers/sync.controller.spec.ts index 07b0d7199f..cae7650d9a 100644 --- a/server/src/controllers/sync.controller.spec.ts +++ b/server/src/controllers/sync.controller.spec.ts @@ -35,7 +35,11 @@ describe(SyncController.name, () => { .post('/sync/stream') .send({ types: ['invalid'] }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest([expect.stringContaining('[types.0] Invalid option: expected one of')])); + expect(body).toEqual( + errorDto.validationError([ + { path: ['types', 0], message: expect.stringContaining('Invalid option: expected one of') }, + ]), + ); expect(ctx.authenticate).toHaveBeenCalled(); }); }); @@ -57,7 +61,9 @@ describe(SyncController.name, () => { const acks = Array.from({ length: 1001 }, (_, i) => `ack-${i}`); const { status, body } = await request(ctx.getHttpServer()).post('/sync/ack').send({ acks }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[acks] Too big: expected array to have <=1000 items'])); + expect(body).toEqual( + errorDto.validationError([{ path: ['acks'], message: 'Too big: expected array to have <=1000 items' }]), + ); expect(ctx.authenticate).toHaveBeenCalled(); }); }); @@ -73,7 +79,11 @@ describe(SyncController.name, () => { .delete('/sync/ack') .send({ types: ['invalid'] }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest([expect.stringContaining('[types.0] Invalid option: expected one of')])); + expect(body).toEqual( + errorDto.validationError([ + { path: ['types', 0], message: expect.stringContaining('Invalid option: expected one of') }, + ]), + ); expect(ctx.authenticate).toHaveBeenCalled(); }); }); diff --git a/server/src/controllers/system-config.controller.spec.ts b/server/src/controllers/system-config.controller.spec.ts index a07dee64ad..4e86aa56db 100644 --- a/server/src/controllers/system-config.controller.spec.ts +++ b/server/src/controllers/system-config.controller.spec.ts @@ -67,8 +67,11 @@ describe(SystemConfigController.name, () => { const { status, body } = await request(ctx.getHttpServer()).put('/system-config').send(config); expect(status).toBe(400); expect(body).toEqual( - errorDto.badRequest([ - '[nightlyTasks.startTime] Invalid input: expected string in HH:mm format, received string', + errorDto.validationError([ + { + path: ['nightlyTasks', 'startTime'], + message: 'Invalid input: expected string in HH:mm format, received string', + }, ]), ); }); @@ -86,7 +89,9 @@ describe(SystemConfigController.name, () => { const { status, body } = await request(ctx.getHttpServer()).put('/system-config').send(config); expect(status).toBe(400); expect(body).toEqual( - errorDto.badRequest(['[nightlyTasks.databaseCleanup] Invalid input: expected boolean, received string']), + errorDto.validationError([ + { path: ['nightlyTasks', 'databaseCleanup'], message: 'Invalid input: expected boolean, received string' }, + ]), ); }); }); @@ -116,7 +121,12 @@ describe(SystemConfigController.name, () => { const { status, body } = await request(ctx.getHttpServer()).put('/system-config').send(config); expect(status).toBe(400); expect(body).toEqual( - errorDto.badRequest(['[image.thumbnail.progressive] Invalid input: expected boolean, received string']), + errorDto.validationError([ + { + path: ['image', 'thumbnail', 'progressive'], + message: 'Invalid input: expected boolean, received string', + }, + ]), ); }); }); diff --git a/server/src/controllers/tag.controller.spec.ts b/server/src/controllers/tag.controller.spec.ts index edd0f27980..907e99bb43 100644 --- a/server/src/controllers/tag.controller.spec.ts +++ b/server/src/controllers/tag.controller.spec.ts @@ -54,7 +54,7 @@ describe(TagController.name, () => { it('should require a valid uuid', async () => { const { status, body } = await request(ctx.getHttpServer()).get(`/tags/123`); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID'])); + expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }])); }); }); diff --git a/server/src/controllers/timeline.controller.spec.ts b/server/src/controllers/timeline.controller.spec.ts index f4c18235e4..b07eb5a78c 100644 --- a/server/src/controllers/timeline.controller.spec.ts +++ b/server/src/controllers/timeline.controller.spec.ts @@ -42,7 +42,9 @@ describe(TimelineController.name, () => { const { status, body } = await request(ctx.getHttpServer()).get('/timeline/buckets').query({ bbox: '1,2,3' }); expect(status).toBe(400); expect(body).toEqual( - errorDto.badRequest(['[bbox] bbox must have 4 comma-separated numbers: west,south,east,north'] as any), + errorDto.validationError([ + { path: ['bbox'], message: 'bbox must have 4 comma-separated numbers: west,south,east,north' }, + ]), ); }); @@ -51,7 +53,7 @@ describe(TimelineController.name, () => { .get('/timeline/buckets') .query({ bbox: '1,2,3,invalid' }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[bbox] bbox parts must be valid numbers'] as any)); + expect(body).toEqual(errorDto.validationError([{ path: ['bbox'], message: 'bbox parts must be valid numbers' }])); }); }); diff --git a/server/src/controllers/user-admin.controller.spec.ts b/server/src/controllers/user-admin.controller.spec.ts index 048f94df5a..b5840a33e1 100644 --- a/server/src/controllers/user-admin.controller.spec.ts +++ b/server/src/controllers/user-admin.controller.spec.ts @@ -78,9 +78,9 @@ describe(UserAdminController.name, () => { .send(dto); expect(status).toBe(400); expect(body).toEqual( - errorDto.badRequest( - expect.arrayContaining(['[quotaSizeInBytes] Invalid input: expected int, received number']), - ), + errorDto.validationError([ + { path: ['quotaSizeInBytes'], message: 'Invalid input: expected int, received number' }, + ]), ); }); @@ -98,9 +98,9 @@ describe(UserAdminController.name, () => { .send(dto); expect(status).toBe(400); expect(body).toEqual( - errorDto.badRequest( - expect.arrayContaining(['[quotaSizeInBytes] Invalid input: expected int, received number']), - ), + errorDto.validationError([ + { path: ['quotaSizeInBytes'], message: 'Invalid input: expected int, received number' }, + ]), ); }); }); @@ -125,9 +125,9 @@ describe(UserAdminController.name, () => { .send({ quotaSizeInBytes: 1.2 }); expect(status).toBe(400); expect(body).toEqual( - errorDto.badRequest( - expect.arrayContaining(['[quotaSizeInBytes] Invalid input: expected int, received number']), - ), + errorDto.validationError([ + { path: ['quotaSizeInBytes'], message: 'Invalid input: expected int, received number' }, + ]), ); }); diff --git a/server/src/controllers/user.controller.spec.ts b/server/src/controllers/user.controller.spec.ts index 3c3e103814..f512e2de39 100644 --- a/server/src/controllers/user.controller.spec.ts +++ b/server/src/controllers/user.controller.spec.ts @@ -43,15 +43,17 @@ describe(UserController.name, () => { expect(ctx.authenticate).toHaveBeenCalled(); }); - for (const key of ['email', 'name']) { + for (const [key, message] of [ + ['email', 'Invalid input: expected email, received object'], + ['name', 'Invalid input: expected string, received null'], + ] as const) { it(`should not allow null ${key}`, async () => { - const dto = { [key]: null }; const { status, body } = await request(ctx.getHttpServer()) .put(`/users/me`) .set('Authorization', `Bearer token`) - .send(dto); + .send({ [key]: null }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest()); + expect(body).toEqual(errorDto.validationError([{ path: [key], message }])); }); } diff --git a/server/src/middleware/global-exception.filter.ts b/server/src/middleware/global-exception.filter.ts index f331df9147..7572274d15 100644 --- a/server/src/middleware/global-exception.filter.ts +++ b/server/src/middleware/global-exception.filter.ts @@ -40,16 +40,16 @@ export class GlobalExceptionFilter implements ExceptionFilter { if (error instanceof ZodValidationException || error instanceof ZodSerializationException) { const zodError = error.getZodError(); if (zodError instanceof ZodError && zodError.issues.length > 0) { - body['message'] = zodError.issues.map((issue) => - issue.path.length > 0 ? `[${issue.path.join('.')}] ${issue.message}` : issue.message, - ); + return { + status, + body: { message: 'Validation failed', errors: zodError.issues }, + }; } } - // remove fields that duplicate the HTTP response line or will be reformatted in a later step + // remove fields injected by NestJS that duplicate the HTTP response line delete body['error']; delete body['statusCode']; - delete body['errors']; return { status, body }; } diff --git a/server/test/medium/responses.ts b/server/test/medium/responses.ts index 2fcab5b2dc..b416b3b904 100644 --- a/server/test/medium/responses.ts +++ b/server/test/medium/responses.ts @@ -25,6 +25,10 @@ export const errorDto = { badRequest: (message: any = null) => ({ message: message ?? expect.anything(), }), + validationError: (errors?: ReadonlyArray<{ path: ReadonlyArray; 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'), }, diff --git a/server/test/small.factory.ts b/server/test/small.factory.ts index ad4dbf7524..082167f647 100644 --- a/server/test/small.factory.ts +++ b/server/test/small.factory.ts @@ -3,6 +3,7 @@ import { AuthDto } from 'src/dtos/auth.dto'; import { QueueStatisticsDto } from 'src/dtos/queue.dto'; import { AssetFileType, Permission, UserStatus } from 'src/enum'; import { v4, v7 } from 'uuid'; +import { expect } from 'vitest'; export const newUuid = () => v4(); export const newUuids = () => @@ -248,5 +249,9 @@ export const factory = { badRequest: (message: any = null) => ({ message: message ?? expect.anything(), }), + validationError: (errors?: ReadonlyArray<{ path: ReadonlyArray; message: string }>) => ({ + message: 'Validation failed', + errors: errors ? expect.arrayContaining(errors.map((e) => expect.objectContaining(e))) : expect.any(Array), + }), }, }; diff --git a/web/src/lib/utils/handle-error.ts b/web/src/lib/utils/handle-error.ts index 0817ab70e9..6a1634b711 100644 --- a/web/src/lib/utils/handle-error.ts +++ b/web/src/lib/utils/handle-error.ts @@ -16,6 +16,18 @@ export function getServerErrorMessage(error: unknown) { } } + if (Array.isArray(data?.errors) && data.errors.length > 0) { + const details = data.errors + .map(({ path, message }) => { + const field = path + .map((segment, i) => (typeof segment === 'number' ? `[${segment}]` : i === 0 ? segment : `.${segment}`)) + .join(''); + return field ? `${field}: ${message}` : message; + }) + .join(', '); + return `${data.message}: ${details}`; + } + return data?.message || error.message; }