diff --git a/backend/src/modules/account/dto/update-profile.dto.ts b/backend/src/modules/account/dto/update-profile.dto.ts index 4f08123..3f097a1 100644 --- a/backend/src/modules/account/dto/update-profile.dto.ts +++ b/backend/src/modules/account/dto/update-profile.dto.ts @@ -5,6 +5,7 @@ import { Matches, MaxLength, } from 'class-validator'; +import { Transform } from 'class-transformer'; import { FACEBOOK_PROFILE_REGEX, @@ -56,6 +57,11 @@ export class UpdateProfileDto { github?: string; @IsOptional() + // Leading/trailing whitespace (e.g. from a copy-paste) parses fine as a URL + // but fails @IsUrl(), which doesn't tolerate it — trim before validating. + @Transform(({ value }: { value: unknown }) => + typeof value === 'string' ? value.trim() : value, + ) @IsUrl() @MaxLength(500) portfolio?: string; diff --git a/backend/src/modules/applications/dto/create-application.dto.ts b/backend/src/modules/applications/dto/create-application.dto.ts index d481a13..a1f2955 100644 --- a/backend/src/modules/applications/dto/create-application.dto.ts +++ b/backend/src/modules/applications/dto/create-application.dto.ts @@ -13,7 +13,7 @@ import { ValidationArguments, ValidateNested, } from 'class-validator'; -import { Type } from 'class-transformer'; +import { Transform, Type } from 'class-transformer'; import { APPLICATION_COUNTRY_CODES } from '@common/constants/countries'; import { @@ -130,6 +130,11 @@ export class CreateApplicationDto { github!: string; @IsOptional() + // Leading/trailing whitespace (e.g. from a copy-paste) parses fine as a URL + // but fails @IsUrl(), which doesn't tolerate it — trim before validating. + @Transform(({ value }: { value: unknown }) => + typeof value === 'string' ? value.trim() : value, + ) @IsUrl() @MaxLength(500) portfolio?: string; diff --git a/backend/test/unit/modules/account/dto.spec.ts b/backend/test/unit/modules/account/dto.spec.ts index 6b4e7c2..4ad97d8 100644 --- a/backend/test/unit/modules/account/dto.spec.ts +++ b/backend/test/unit/modules/account/dto.spec.ts @@ -41,6 +41,12 @@ describe('UpdateProfileDto', () => { 'name', ); }); + + it('accepts a portfolio URL with incidental surrounding whitespace', () => { + expect( + failingProps(UpdateProfileDto, { portfolio: ' https://ada.dev ' }), + ).toEqual([]); + }); }); describe('UpdateWalletsDto', () => { diff --git a/backend/test/unit/modules/applications/dto.spec.ts b/backend/test/unit/modules/applications/dto.spec.ts index ae93a82..1f2cc3f 100644 --- a/backend/test/unit/modules/applications/dto.spec.ts +++ b/backend/test/unit/modules/applications/dto.spec.ts @@ -47,6 +47,18 @@ describe('CreateApplicationDto', () => { ); }); + it('accepts a portfolio URL with incidental surrounding whitespace', () => { + expect( + failingProps({ ...VALID, portfolio: ' https://ada.dev ' }), + ).toEqual([]); + }); + + it('rejects a portfolio value that is not a URL once trimmed', () => { + expect(failingProps({ ...VALID, portfolio: ' not a url ' })).toContain( + 'portfolio', + ); + }); + it('accepts a valid EVM wallet address', () => { expect( failingProps({ diff --git a/frontend/src/constants/shared/social-profiles.ts b/frontend/src/constants/shared/social-profiles.ts index a4b61a1..8ef0e7d 100644 --- a/frontend/src/constants/shared/social-profiles.ts +++ b/frontend/src/constants/shared/social-profiles.ts @@ -47,9 +47,15 @@ export const SOCIAL_PROFILE_MESSAGES = { export const optionalMatching = (re: RegExp, message: string) => z.string().refine((val) => !val || re.test(val), message); -/** A string field that is allowed to be blank but, if filled, must be a URL. */ +/** + * A string field that is allowed to be blank but, if filled, must be a URL. + * Trims first: the WHATWG URL parser silently accepts leading/trailing + * whitespace (so untrimmed input still passes this check), but the backend's + * validator does not — trimming here keeps the value valid end-to-end. + */ export const optionalUrl = z .string() + .trim() .refine( (val) => !val || z.string().url().safeParse(val).success, "Enter a valid URL (e.g. https://...)", diff --git a/frontend/tests/constants/apply-form.test.ts b/frontend/tests/constants/apply-form.test.ts index 719b19b..e37024c 100644 --- a/frontend/tests/constants/apply-form.test.ts +++ b/frontend/tests/constants/apply-form.test.ts @@ -82,6 +82,15 @@ describe("APPLICATION_FORM_SCHEMA", () => { }); expect(result.success).toBe(true); }); + + it("accepts and trims a portfolio URL with surrounding whitespace", () => { + const result = APPLICATION_FORM_SCHEMA.safeParse({ + ...VALID, + portfolio: " https://ada.dev ", + }); + expect(result.success).toBe(true); + if (result.success) expect(result.data.portfolio).toBe("https://ada.dev"); + }); }); describe("isApplicationStepSlug", () => { diff --git a/frontend/tests/features/profile-form-schema.test.ts b/frontend/tests/features/profile-form-schema.test.ts index 7c09c67..2f5ab9c 100644 --- a/frontend/tests/features/profile-form-schema.test.ts +++ b/frontend/tests/features/profile-form-schema.test.ts @@ -63,4 +63,13 @@ describe("PROFILE_FORM_SCHEMA", () => { }); expect(result.success).toBe(false); }); + + it("accepts and trims a portfolio URL with surrounding whitespace", () => { + const result = PROFILE_FORM_SCHEMA.safeParse({ + ...blank, + portfolio: " https://ada.dev ", + }); + expect(result.success).toBe(true); + if (result.success) expect(result.data.portfolio).toBe("https://ada.dev"); + }); });