refactor!: migrate class-validator to zod (#26597)

This commit is contained in:
Timon
2026-04-14 23:39:03 +02:00
committed by GitHub
parent 3753b7a4d1
commit 7d8f843be6
318 changed files with 7830 additions and 8316 deletions
+4 -1
View File
@@ -13,6 +13,7 @@ import { SessionFactory } from 'test/factories/session.factory';
import { UserFactory } from 'test/factories/user.factory';
import { sharedLinkStub } from 'test/fixtures/shared-link.stub';
import { systemConfigStub } from 'test/fixtures/system-config.stub';
import { userStub } from 'test/fixtures/user.stub';
import { newUuid } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils';
@@ -209,11 +210,13 @@ describe(AuthService.name, () => {
it('should sign up the admin', async () => {
mocks.user.getAdmin.mockResolvedValue(void 0);
mocks.user.create.mockResolvedValue({
...userStub.admin,
...dto,
id: 'admin',
name: 'immich admin',
createdAt: new Date('2021-01-01'),
metadata: [] as UserMetadataItem[],
} as unknown as UserAdmin);
} as UserAdmin);
await expect(sut.adminSignUp(dto)).resolves.toMatchObject({
avatarColor: expect.any(String),
+2 -3
View File
@@ -1,5 +1,4 @@
import { BadRequestException, ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common';
import { isString } from 'class-validator';
import { parse } from 'cookie';
import { DateTime } from 'luxon';
import { IncomingHttpHeaders } from 'node:http';
@@ -312,7 +311,7 @@ export class AuthService extends BaseService {
const storageLabel = this.getClaim(profile, {
key: storageLabelClaim,
default: '',
isValid: isString,
isValid: (value: unknown): value is string => typeof value === 'string',
});
const storageQuota = this.getClaim(profile, {
key: storageQuotaClaim,
@@ -322,7 +321,7 @@ export class AuthService extends BaseService {
const role = this.getClaim<'admin' | 'user'>(profile, {
key: roleClaim,
default: 'user',
isValid: (value: unknown) => isString(value) && ['admin', 'user'].includes(value),
isValid: (value: unknown) => typeof value === 'string' && ['admin', 'user'].includes(value),
});
user = await this.createUser({
+1
View File
@@ -283,6 +283,7 @@ export class LibraryService extends BaseService {
private async validateImportPath(importPath: string): Promise<ValidateLibraryImportPathResponseDto> {
const validation = new ValidateLibraryImportPathResponseDto();
validation.importPath = importPath;
validation.isValid = false;
if (StorageCore.isImmichPath(importPath)) {
validation.message = 'Cannot use media upload folder for external libraries';
@@ -1,4 +1,3 @@
import { plainToInstance } from 'class-transformer';
import { defaults, SystemConfig } from 'src/config';
import { SystemConfigDto } from 'src/dtos/system-config.dto';
import { AssetFileType, JobName, JobStatus, UserMetadataKey } from 'src/enum';
@@ -102,7 +101,7 @@ describe(NotificationService.name, () => {
it('skips smtp validation with DTO when there are no changes', async () => {
const oldConfig = { ...configs.smtpEnabled };
const newConfig = plainToInstance(SystemConfigDto, configs.smtpEnabled);
const newConfig = configs.smtpEnabled as SystemConfigDto;
await expect(sut.onConfigValidate({ oldConfig, newConfig })).resolves.not.toThrow();
expect(mocks.email.verifySmtp).not.toHaveBeenCalled();
+2 -11
View File
@@ -1,11 +1,9 @@
import { Plugin as ExtismPlugin, newPlugin } from '@extism/extism';
import { BadRequestException, Injectable } from '@nestjs/common';
import { plainToInstance } from 'class-transformer';
import { validateOrReject } from 'class-validator';
import { join } from 'node:path';
import { Asset, WorkflowAction, WorkflowFilter } from 'src/database';
import { OnEvent, OnJob } from 'src/decorators';
import { PluginManifestDto } from 'src/dtos/plugin-manifest.dto';
import { PluginManifestDto, PluginManifestSchema } from 'src/dtos/plugin-manifest.dto';
import { mapPlugin, PluginResponseDto, PluginTriggerResponseDto } from 'src/dtos/plugin.dto';
import { JobName, JobStatus, PluginTriggerType, QueueName } from 'src/enum';
import { pluginTriggers } from 'src/plugins';
@@ -138,14 +136,7 @@ export class PluginService extends BaseService {
private async readAndValidateManifest(manifestPath: string): Promise<PluginManifestDto> {
const content = await this.storageRepository.readTextFile(manifestPath);
const manifestData = JSON.parse(content);
const manifest = plainToInstance(PluginManifestDto, manifestData);
await validateOrReject(manifest, {
whitelist: true,
forbidNonWhitelisted: true,
});
return manifest;
return PluginManifestSchema.parse(manifestData);
}
///////////////////////////////////////////
+2 -3
View File
@@ -1,5 +1,4 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { ClassConstructor } from 'class-transformer';
import { SystemConfig } from 'src/config';
import { OnEvent } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
@@ -39,7 +38,7 @@ const asNightlyTasksCron = (config: SystemConfig) => {
@Injectable()
export class QueueService extends BaseService {
private services: ClassConstructor<unknown>[] = [];
private services: (new (...args: any[]) => unknown)[] = [];
private nightlyJobsLock = false;
@OnEvent({ name: 'ConfigInit' })
@@ -96,7 +95,7 @@ export class QueueService extends BaseService {
}
}
setServices(services: ClassConstructor<unknown>[]) {
setServices(services: (new (...args: any[]) => unknown)[]) {
this.services = services;
}
+6
View File
@@ -138,6 +138,12 @@ export class ServerService extends BaseService {
async getStatistics(): Promise<ServerStatsResponseDto> {
const userStats: UserStatsQueryResponse[] = await this.userRepository.getUserStats();
const serverStats = new ServerStatsResponseDto();
serverStats.photos ??= 0;
serverStats.videos ??= 0;
serverStats.usage ??= 0;
serverStats.usagePhotos ??= 0;
serverStats.usageVideos ??= 0;
serverStats.usageByUser ??= [];
for (const user of userStats) {
const usage = new UsageByUserDto();
@@ -311,9 +311,7 @@ describe(SystemConfigService.name, () => {
mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' }));
mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify({ library: { scan: { cronExpression: 'foo' } } }));
await expect(sut.getSystemConfig()).rejects.toThrow(
'library.scan.cronExpression has failed the following constraints: cronValidator',
);
await expect(sut.getSystemConfig()).rejects.toThrow('[library.scan.cronExpression] Invalid cron expression');
});
it('should log errors with the config file', async () => {
@@ -402,10 +400,26 @@ describe(SystemConfigService.name, () => {
});
const tests = [
{ should: 'validate numbers', config: { ffmpeg: { crf: 'not-a-number' } } },
{ should: 'validate booleans', config: { oauth: { enabled: 'invalid' } } },
{ should: 'validate enums', config: { ffmpeg: { transcode: 'unknown' } } },
{ should: 'validate required oauth fields', config: { oauth: { enabled: true } } },
{
should: 'validate numbers',
config: { ffmpeg: { crf: 'not-a-number' } },
throws: '[ffmpeg.crf] Invalid input: expected number, received NaN',
},
{
should: 'validate booleans',
config: { oauth: { enabled: 'invalid' } },
throws: '[oauth.enabled] Invalid input: expected boolean, received string',
},
{
should: 'validate enums',
config: { ffmpeg: { transcode: 'unknown' } },
throws: '[ffmpeg.transcode] Invalid option: expected one of',
},
{
should: 'validate required oauth fields',
config: { oauth: { enabled: true } },
check: (c: SystemConfig) => expect(c.oauth.enabled).toBe(true),
},
{ should: 'warn for top level unknown options', warn: true, config: { unknownOption: true } },
{ should: 'warn for nested unknown options', warn: true, config: { ffmpeg: { unknownOption: true } } },
];
@@ -415,11 +429,14 @@ describe(SystemConfigService.name, () => {
mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' }));
mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify(test.config));
if (test.warn) {
if (test.throws) {
await expect(sut.getSystemConfig()).rejects.toThrow(test.throws);
} else if (test.warn) {
await sut.getSystemConfig();
expect(mocks.logger.warn).toHaveBeenCalled();
} else {
await expect(sut.getSystemConfig()).rejects.toBeInstanceOf(Error);
const config = await sut.getSystemConfig();
test.check!(config);
}
});
}
+1 -2
View File
@@ -1,5 +1,4 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { instanceToPlain } from 'class-transformer';
import _ from 'lodash';
import { defaults } from 'src/config';
import { OnEvent } from 'src/decorators';
@@ -61,7 +60,7 @@ export class SystemConfigService extends BaseService {
@OnEvent({ name: 'ConfigValidate' })
onConfigValidate({ newConfig, oldConfig }: ArgOf<'ConfigValidate'>) {
const { logLevel } = this.configRepository.getEnv();
if (!_.isEqual(instanceToPlain(newConfig.logging), oldConfig.logging) && logLevel) {
if (!_.isEqual(toPlainObject(newConfig.logging), oldConfig.logging) && logLevel) {
throw new Error('Logging cannot be changed while the environment variable IMMICH_LOG_LEVEL is set.');
}
}