From aaf300a05988402b1eea1a8058eb30731c675ee3 Mon Sep 17 00:00:00 2001 From: Ben McCann <322311+benmccann@users.noreply.github.com> Date: Sat, 11 Jul 2026 07:53:45 -0700 Subject: [PATCH] update tests to account for zod error messages --- .../asset-upload.controller.spec.ts | 93 ++++++++----------- server/src/dtos/asset-upload.dto.ts | 4 +- .../src/services/asset-upload.service.spec.ts | 7 +- server/src/utils/request.ts | 4 +- 4 files changed, 47 insertions(+), 61 deletions(-) diff --git a/server/src/controllers/asset-upload.controller.spec.ts b/server/src/controllers/asset-upload.controller.spec.ts index 9a96939bb0..fe6b16cd54 100644 --- a/server/src/controllers/asset-upload.controller.spec.ts +++ b/server/src/controllers/asset-upload.controller.spec.ts @@ -18,6 +18,12 @@ const makeAssetData = (overrides?: Partial): string => { }); }; +const expectValidationIssue = (body: unknown, path: string[], message: string) => { + expect(body).toEqual( + expect.objectContaining({ errors: expect.arrayContaining([expect.objectContaining({ path, message })]) }), + ); +}; + describe(AssetUploadController.name, () => { let ctx: ControllerContext; let buffer: Buffer; @@ -70,11 +76,7 @@ describe(AssetUploadController.name, () => { .send(buffer); expect(status).toBe(400); - expect(body).toEqual( - expect.objectContaining({ - message: expect.arrayContaining(['version must not be less than 3']), - }), - ); + expectValidationIssue(body, ['upload-draft-interop-version'], 'Too small: expected number to be >=3'); }); it('should require X-Immich-Asset-Data header', async () => { @@ -87,7 +89,7 @@ describe(AssetUploadController.name, () => { .send(buffer); expect(status).toBe(400); - expect(body).toEqual(expect.objectContaining({ message: 'x-immich-asset-data header is required' })); + expectValidationIssue(body, ['x-immich-asset-data'], 'Invalid input: expected string, received undefined'); }); it('should require Repr-Digest header', async () => { @@ -100,7 +102,7 @@ describe(AssetUploadController.name, () => { .send(buffer); expect(status).toBe(400); - expect(body).toEqual(expect.objectContaining({ message: 'Missing repr-digest header' })); + expectValidationIssue(body, ['repr-digest'], 'Invalid input: expected string, received undefined'); }); it('should allow conventional upload without Upload-Complete header', async () => { @@ -124,7 +126,7 @@ describe(AssetUploadController.name, () => { .send(buffer); expect(status).toBe(400); - expect(body).toEqual(expect.objectContaining({ message: 'Missing upload-length header' })); + expectValidationIssue(body, ['upload-length'], 'Missing upload-length header'); }); it('should infer upload length from content length if complete upload', async () => { @@ -150,7 +152,7 @@ describe(AssetUploadController.name, () => { .send(buffer); expect(status).toBe(400); - expect(body).toEqual(expect.objectContaining({ message: 'Invalid repr-digest header' })); + expectValidationIssue(body, ['repr-digest'], 'Invalid repr-digest header'); }); it('should validate device-asset-id is required in asset data', async () => { @@ -171,10 +173,10 @@ describe(AssetUploadController.name, () => { .send(buffer); expect(status).toBe(400); - expect(body).toEqual( - expect.objectContaining({ - message: expect.arrayContaining([expect.stringContaining('deviceAssetId')]), - }), + expectValidationIssue( + body, + ['x-immich-asset-data', 'deviceAssetId'], + 'Invalid input: expected string, received undefined', ); }); @@ -196,10 +198,10 @@ describe(AssetUploadController.name, () => { .send(buffer); expect(status).toBe(400); - expect(body).toEqual( - expect.objectContaining({ - message: expect.arrayContaining([expect.stringContaining('deviceId')]), - }), + expectValidationIssue( + body, + ['x-immich-asset-data', 'deviceId'], + 'Invalid input: expected string, received undefined', ); }); @@ -221,10 +223,10 @@ describe(AssetUploadController.name, () => { .send(buffer); expect(status).toBe(400); - expect(body).toEqual( - expect.objectContaining({ - message: expect.arrayContaining([expect.stringContaining('filename')]), - }), + expectValidationIssue( + body, + ['x-immich-asset-data', 'filename'], + 'Invalid input: expected string, received undefined', ); }); @@ -254,7 +256,7 @@ describe(AssetUploadController.name, () => { .send(buffer); expect(status).toBe(400); - expect(body).toEqual(expect.objectContaining({ message: 'upload-complete must be a structured boolean value' })); + expectValidationIssue(body, ['upload-complete'], 'upload-complete must be a structured boolean value'); }); it('should validate Upload-Length is a positive integer', async () => { @@ -268,11 +270,7 @@ describe(AssetUploadController.name, () => { .send(buffer); expect(status).toBe(400); - expect(body).toEqual( - expect.objectContaining({ - message: expect.arrayContaining(['uploadLength must not be less than 1']), - }), - ); + expectValidationIssue(body, ['upload-length'], 'Too small: expected number to be >=1'); }); }); @@ -292,11 +290,7 @@ describe(AssetUploadController.name, () => { .send(Buffer.from('test')); expect(status).toBe(400); - expect(body).toEqual( - expect.objectContaining({ - message: expect.arrayContaining(['version must be an integer number', 'version must not be less than 3']), - }), - ); + expectValidationIssue(body, ['upload-draft-interop-version'], 'Invalid input: expected number, received NaN'); }); it('should require Upload-Offset header', async () => { @@ -307,14 +301,7 @@ describe(AssetUploadController.name, () => { .send(Buffer.from('test')); expect(status).toBe(400); - expect(body).toEqual( - expect.objectContaining({ - message: expect.arrayContaining([ - 'uploadOffset must be an integer number', - 'uploadOffset must not be less than 0', - ]), - }), - ); + expectValidationIssue(body, ['upload-offset'], 'Invalid input: expected number, received NaN'); }); it('should require Upload-Complete header', async () => { @@ -326,7 +313,7 @@ describe(AssetUploadController.name, () => { .send(Buffer.from('test')); expect(status).toBe(400); - expect(body).toEqual(expect.objectContaining({ message: ['uploadComplete must be a boolean value'] })); + expectValidationIssue(body, ['upload-complete'], 'is required'); }); it('should validate UUID parameter', async () => { @@ -338,7 +325,11 @@ describe(AssetUploadController.name, () => { .send(Buffer.from('test')); expect(status).toBe(400); - expect(body).toEqual(expect.objectContaining({ message: ['id must be a UUID'] })); + expect(body).toEqual( + expect.objectContaining({ + errors: expect.arrayContaining([expect.objectContaining({ message: 'Invalid UUID', path: ['id'] })]), + }), + ); }); it('should validate Upload-Offset is a non-negative integer', async () => { @@ -350,11 +341,7 @@ describe(AssetUploadController.name, () => { .send(Buffer.from('test')); expect(status).toBe(400); - expect(body).toEqual( - expect.objectContaining({ - message: expect.arrayContaining(['uploadOffset must not be less than 0']), - }), - ); + expectValidationIssue(body, ['upload-offset'], 'Too small: expected number to be >=0'); }); it('should require Content-Type: application/partial-upload for version >= 6', async () => { @@ -367,11 +354,7 @@ describe(AssetUploadController.name, () => { .send(Buffer.from('test')); expect(status).toBe(400); - expect(body).toEqual( - expect.objectContaining({ - message: ['contentType must be equal to application/partial-upload'], - }), - ); + expectValidationIssue(body, ['content-type'], 'must be equal to application/partial-upload'); }); it('should allow other Content-Type for version < 6', async () => { @@ -416,7 +399,11 @@ describe(AssetUploadController.name, () => { const { status, body } = await request(ctx.getHttpServer()).delete('/upload/invalid-uuid'); expect(status).toBe(400); - expect(body).toEqual(expect.objectContaining({ message: ['id must be a UUID'] })); + expect(body).toEqual( + expect.objectContaining({ + errors: expect.arrayContaining([expect.objectContaining({ message: 'Invalid UUID', path: ['id'] })]), + }), + ); }); }); diff --git a/server/src/dtos/asset-upload.dto.ts b/server/src/dtos/asset-upload.dto.ts index 2af0c06131..69bfd47a20 100644 --- a/server/src/dtos/asset-upload.dto.ts +++ b/server/src/dtos/asset-upload.dto.ts @@ -88,7 +88,7 @@ const StartUploadSchema = BaseUploadHeadersInputSchema.extend({ const complete = parseUploadComplete(headers, ctx); const length = headers[Header.UploadLength] ?? (complete === false ? undefined : headers[Header.ContentLength]); if (!length) { - ctx.addIssue({ code: 'custom', message: `Missing ${Header.UploadLength} header` }); + ctx.addIssue({ code: 'custom', path: [Header.UploadLength], message: `Missing ${Header.UploadLength} header` }); return z.NEVER; } return { @@ -185,7 +185,7 @@ function parseUploadComplete( return !incomplete; } if (required && headers[Header.UploadComplete] === undefined && headers[Header.UploadIncomplete] === undefined) { - ctx.addIssue({ code: 'custom', message: `${Header.UploadComplete} is required` }); + ctx.addIssue({ code: 'custom', path: [Header.UploadComplete], message: 'is required' }); return z.NEVER; } } diff --git a/server/src/services/asset-upload.service.spec.ts b/server/src/services/asset-upload.service.spec.ts index 9c54e2af47..9bec05d6e5 100644 --- a/server/src/services/asset-upload.service.spec.ts +++ b/server/src/services/asset-upload.service.spec.ts @@ -12,6 +12,7 @@ describe(AssetUploadService.name, () => { beforeEach(() => { ({ sut, mocks } = newTestService(AssetUploadService)); + mocks.asset.setComplete.mockResolvedValue({ id: factory.uuid() } as any); }); describe('onStart', () => { @@ -51,8 +52,6 @@ describe(AssetUploadService.name, () => { id: assetId, ownerId: authStub.user1.user.id, checksum: mockDto.checksum, - deviceAssetId: mockDto.assetData.deviceAssetId, - deviceId: mockDto.assetData.deviceId, fileCreatedAt: mockDto.assetData.fileCreatedAt, fileModifiedAt: mockDto.assetData.fileModifiedAt, type: AssetType.Image, @@ -225,7 +224,7 @@ describe(AssetUploadService.name, () => { it('should mark asset as complete and queue metadata extraction job', async () => { await sut.onComplete({ id: assetId, path, fileModifiedAt }); - expect(mocks.asset.setComplete).toHaveBeenCalledWith(assetId); + expect(mocks.asset.setComplete).toHaveBeenCalledWith(assetId, undefined); expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.AssetExtractMetadata, data: { id: assetId, source: 'upload' }, @@ -378,7 +377,7 @@ describe(AssetUploadService.name, () => { const result = await sut.removeStaleUpload({ id: assetId }); expect(result).toBe(JobStatus.Success); - expect(mocks.asset.setComplete).toHaveBeenCalledWith(assetId); + expect(mocks.asset.setComplete).toHaveBeenCalledWith(assetId, undefined); expect(mocks.storage.unlink).not.toHaveBeenCalled(); }); diff --git a/server/src/utils/request.ts b/server/src/utils/request.ts index 4b52ae2d31..d2f4cd54e3 100644 --- a/server/src/utils/request.ts +++ b/server/src/utils/request.ts @@ -1,4 +1,4 @@ -import { BadRequestException } from '@nestjs/common'; +import { ZodValidationException } from 'nestjs-zod'; import { IncomingHttpHeaders } from 'node:http'; import { UAParser } from 'ua-parser-js'; import z from 'zod'; @@ -31,5 +31,5 @@ export function validateSyncOrReject(cls: { schema: z.ZodType }, obj: unkn if (result.success) { return result.data; } - throw new BadRequestException(result.error.issues.map(({ message }) => message)); + throw new ZodValidationException(result.error); }