diff --git a/apps/api/src/domains/dto/domains-settings.dto.spec.ts b/apps/api/src/domains/dto/domains-settings.dto.spec.ts new file mode 100644 index 0000000..6e2fa42 --- /dev/null +++ b/apps/api/src/domains/dto/domains-settings.dto.spec.ts @@ -0,0 +1,41 @@ +import 'reflect-metadata'; +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { describe, expect, it } from 'vitest'; +import { SaveDomainsSettingsDto } from './domains-settings.dto'; + +async function errorsOf(plain: Record) { + const errors = await validate(plainToInstance(SaveDomainsSettingsDto, plain)); + return errors.map((e) => e.property).sort(); +} + +describe('SaveDomainsSettingsDto — null ist keine fehlende Angabe', () => { + it('ein leerer Rumpf und nur gesetzte Felder sind gueltig', async () => { + expect(await errorsOf({})).toEqual([]); + expect( + await errorsOf({ + environment: 'DEMO', + demoUser: 'u', + demoPassword: '', + defaultNameServers: ['ns1.example.com', 'ns2.example.com'], + }), + ).toEqual([]); + }); + + it.each([ + 'environment', + 'confirmLive', + 'demoUser', + 'demoPassword', + 'liveUser', + 'livePassword', + 'defaultNameServers', + ])('%s: null -> Validierungsfehler (400) statt TypeError im Dienst', async (field) => { + expect(await errorsOf({ [field]: null })).toEqual([field]); + }); + + it('Kontextnummern duerfen ausdruecklich null sein (Zugang loeschen)', async () => { + expect(await errorsOf({ demoContext: null, liveContext: null })).toEqual([]); + expect(await errorsOf({ demoContext: 'x' })).toEqual(['demoContext']); + }); +}); diff --git a/apps/api/src/domains/dto/domains-settings.dto.ts b/apps/api/src/domains/dto/domains-settings.dto.ts index cf8ad5d..3dbf7c7 100644 --- a/apps/api/src/domains/dto/domains-settings.dto.ts +++ b/apps/api/src/domains/dto/domains-settings.dto.ts @@ -4,7 +4,6 @@ import { IsBoolean, IsIn, IsInt, - IsOptional, IsString, Max, MaxLength, @@ -12,6 +11,17 @@ import { ValidateIf, } from 'class-validator'; +/** + * Teilspeichern: ein Feld darf FEHLEN (`undefined`), aber nicht `null` sein — + * `@IsOptional()` liesse `null` durch und der Dienst scheiterte dann mit einem + * 500 statt einer 400 (`null.trim()`, `null.map()`, NOT-NULL-Spalte). + */ +const isPresent = (_object: unknown, value: unknown): boolean => value !== undefined; + +/** Kontextnummern duerfen zusaetzlich ausdruecklich `null` sein (= Zugang loeschen). */ +const isPresentOrNull = (_object: unknown, value: unknown): boolean => + value !== null && value !== undefined; + /** * Einstellungen des Moduls Domains (quick-261008-dts). Jedes Feld ist * optional — gespeichert wird nur, was mitgeschickt wird (jede Karte der @@ -19,48 +29,48 @@ import { * Passwort laesst das gespeicherte unveraendert. */ export class SaveDomainsSettingsDto { - @IsOptional() + @ValidateIf(isPresent) @IsIn(['DEMO', 'LIVE']) environment?: 'DEMO' | 'LIVE'; /** Ausdrueckliche Bestaetigung fuer den Wechsel auf das Live-System. */ - @IsOptional() + @ValidateIf(isPresent) @IsBoolean() confirmLive?: boolean; - @IsOptional() + @ValidateIf(isPresent) @IsString() @MaxLength(100) demoUser?: string; - @IsOptional() + @ValidateIf(isPresent) @IsString() @MaxLength(200) demoPassword?: string; - @ValidateIf((_o, value) => value !== null && value !== undefined) + @ValidateIf(isPresentOrNull) @IsInt() @Min(1) @Max(2147483647) demoContext?: number | null; - @IsOptional() + @ValidateIf(isPresent) @IsString() @MaxLength(100) liveUser?: string; - @IsOptional() + @ValidateIf(isPresent) @IsString() @MaxLength(200) livePassword?: string; - @ValidateIf((_o, value) => value !== null && value !== undefined) + @ValidateIf(isPresentOrNull) @IsInt() @Min(1) @Max(2147483647) liveContext?: number | null; - @IsOptional() + @ValidateIf(isPresent) @IsArray() @ArrayMaxSize(6) @IsString({ each: true })