Skip to content

Commit 8324c51

Browse files
authored
Merge pull request #2373 from trycompai/mariano/fix-vendor-patch-400
[dev] [Marfuen] mariano/fix-vendor-patch-400
2 parents 11960ad + 7631e2b commit 8324c51

File tree

2 files changed

+196
-3
lines changed

2 files changed

+196
-3
lines changed
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { plainToInstance } from 'class-transformer';
2+
import { validate } from 'class-validator';
3+
import { UpdateVendorDto } from './update-vendor.dto';
4+
5+
/**
6+
* Mirrors the global ValidationPipe config from main.ts:
7+
* whitelist: true, transform: true, enableImplicitConversion: true
8+
*/
9+
function toDto(plain: Record<string, unknown>): UpdateVendorDto {
10+
return plainToInstance(UpdateVendorDto, plain, {
11+
enableImplicitConversion: true,
12+
});
13+
}
14+
15+
describe('UpdateVendorDto', () => {
16+
it('should accept a valid full update payload', async () => {
17+
const dto = toDto({
18+
name: 'Acronis',
19+
description: 'Backup solutions provider',
20+
category: 'software_as_a_service',
21+
status: 'assessed',
22+
website: 'https://www.acronis.com',
23+
isSubProcessor: false,
24+
assigneeId: 'mem_abc123',
25+
});
26+
const errors = await validate(dto, { whitelist: true, forbidNonWhitelisted: true });
27+
expect(errors).toHaveLength(0);
28+
});
29+
30+
it('should accept a minimal update (single field)', async () => {
31+
const dto = toDto({ website: 'https://www.acronis.com' });
32+
const errors = await validate(dto, { whitelist: true, forbidNonWhitelisted: true });
33+
expect(errors).toHaveLength(0);
34+
});
35+
36+
it('should accept an empty body (no fields to update)', async () => {
37+
const dto = toDto({});
38+
const errors = await validate(dto, { whitelist: true, forbidNonWhitelisted: true });
39+
expect(errors).toHaveLength(0);
40+
});
41+
42+
// ── The bug this DTO fix addresses ────────────────────────────────
43+
it('should accept empty description (vendors from onboarding)', async () => {
44+
const dto = toDto({
45+
name: 'Acronis',
46+
description: '',
47+
category: 'software_as_a_service',
48+
status: 'assessed',
49+
website: 'https://www.acronis.com',
50+
isSubProcessor: false,
51+
});
52+
const errors = await validate(dto, { whitelist: true, forbidNonWhitelisted: true });
53+
expect(errors).toHaveLength(0);
54+
});
55+
56+
it('should still reject empty name', async () => {
57+
const dto = toDto({ name: '' });
58+
const errors = await validate(dto, { whitelist: true, forbidNonWhitelisted: true });
59+
expect(errors.length).toBeGreaterThan(0);
60+
expect(errors[0].property).toBe('name');
61+
});
62+
63+
// ── assigneeId: null (unassigned vendor) ──────────────────────────
64+
it('should accept assigneeId: null', async () => {
65+
const dto = toDto({ assigneeId: null });
66+
const errors = await validate(dto, { whitelist: true, forbidNonWhitelisted: true });
67+
expect(errors).toHaveLength(0);
68+
});
69+
70+
// ── website handling ──────────────────────────────────────────────
71+
it('should transform empty website to undefined (skip validation)', async () => {
72+
const dto = toDto({ website: '' });
73+
const errors = await validate(dto, { whitelist: true, forbidNonWhitelisted: true });
74+
expect(errors).toHaveLength(0);
75+
expect(dto.website).toBeUndefined();
76+
});
77+
78+
it('should accept a valid website URL', async () => {
79+
const dto = toDto({ website: 'https://www.cloudflare.com' });
80+
const errors = await validate(dto, { whitelist: true, forbidNonWhitelisted: true });
81+
expect(errors).toHaveLength(0);
82+
});
83+
84+
it('should reject an invalid website URL', async () => {
85+
const dto = toDto({ website: 'not-a-url' });
86+
const errors = await validate(dto, { whitelist: true, forbidNonWhitelisted: true });
87+
expect(errors.length).toBeGreaterThan(0);
88+
expect(errors[0].property).toBe('website');
89+
});
90+
91+
// ── enum validation ───────────────────────────────────────────────
92+
it('should reject invalid category enum', async () => {
93+
const dto = toDto({ category: 'invalid_category' });
94+
const errors = await validate(dto, { whitelist: true, forbidNonWhitelisted: true });
95+
expect(errors.length).toBeGreaterThan(0);
96+
expect(errors[0].property).toBe('category');
97+
});
98+
99+
it('should reject invalid status enum', async () => {
100+
const dto = toDto({ status: 'invalid_status' });
101+
const errors = await validate(dto, { whitelist: true, forbidNonWhitelisted: true });
102+
expect(errors.length).toBeGreaterThan(0);
103+
expect(errors[0].property).toBe('status');
104+
});
105+
106+
// ── forbidNonWhitelisted ──────────────────────────────────────────
107+
it('should reject unknown properties', async () => {
108+
const dto = toDto({ name: 'Acronis', unknownField: 'value' });
109+
const errors = await validate(dto, { whitelist: true, forbidNonWhitelisted: true });
110+
expect(errors.length).toBeGreaterThan(0);
111+
expect(errors.some((e) => e.property === 'unknownField')).toBe(true);
112+
});
113+
});
Lines changed: 83 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,84 @@
1-
import { PartialType } from '@nestjs/swagger';
2-
import { CreateVendorDto } from './create-vendor.dto';
1+
import { ApiPropertyOptional } from '@nestjs/swagger';
2+
import {
3+
IsString,
4+
IsNotEmpty,
5+
IsOptional,
6+
IsEnum,
7+
IsUrl,
8+
IsBoolean,
9+
} from 'class-validator';
10+
import { Transform } from 'class-transformer';
11+
import {
12+
VendorCategory,
13+
VendorStatus,
14+
Likelihood,
15+
Impact,
16+
} from '@trycompai/db';
317

4-
export class UpdateVendorDto extends PartialType(CreateVendorDto) {}
18+
/**
19+
* DTO for PATCH /vendors/:id
20+
*
21+
* Defined explicitly rather than using PartialType(CreateVendorDto) because
22+
* PartialType preserves @IsNotEmpty() — which rejects empty strings even
23+
* when @IsOptional() is added. For PATCH, empty-string fields like
24+
* `description: ""` (common for vendors created during onboarding) should
25+
* not cause a 400.
26+
*/
27+
export class UpdateVendorDto {
28+
@ApiPropertyOptional({ description: 'Vendor name' })
29+
@IsOptional()
30+
@IsString()
31+
@IsNotEmpty()
32+
name?: string;
33+
34+
@ApiPropertyOptional({ description: 'Vendor description' })
35+
@IsOptional()
36+
@IsString()
37+
description?: string;
38+
39+
@ApiPropertyOptional({ description: 'Vendor category', enum: VendorCategory })
40+
@IsOptional()
41+
@IsEnum(VendorCategory)
42+
category?: VendorCategory;
43+
44+
@ApiPropertyOptional({ description: 'Assessment status', enum: VendorStatus })
45+
@IsOptional()
46+
@IsEnum(VendorStatus)
47+
status?: VendorStatus;
48+
49+
@ApiPropertyOptional({ description: 'Inherent probability', enum: Likelihood })
50+
@IsOptional()
51+
@IsEnum(Likelihood)
52+
inherentProbability?: Likelihood;
53+
54+
@ApiPropertyOptional({ description: 'Inherent impact', enum: Impact })
55+
@IsOptional()
56+
@IsEnum(Impact)
57+
inherentImpact?: Impact;
58+
59+
@ApiPropertyOptional({ description: 'Residual probability', enum: Likelihood })
60+
@IsOptional()
61+
@IsEnum(Likelihood)
62+
residualProbability?: Likelihood;
63+
64+
@ApiPropertyOptional({ description: 'Residual impact', enum: Impact })
65+
@IsOptional()
66+
@IsEnum(Impact)
67+
residualImpact?: Impact;
68+
69+
@ApiPropertyOptional({ description: 'Vendor website URL' })
70+
@IsOptional()
71+
@IsUrl()
72+
@Transform(({ value }) => (value === '' ? undefined : value))
73+
website?: string;
74+
75+
@ApiPropertyOptional({ description: 'Whether the vendor is a sub-processor' })
76+
@IsOptional()
77+
@IsBoolean()
78+
isSubProcessor?: boolean;
79+
80+
@ApiPropertyOptional({ description: 'Assignee member ID' })
81+
@IsOptional()
82+
@IsString()
83+
assigneeId?: string;
84+
}

0 commit comments

Comments
 (0)