From bc6fcd1dd8e3b3b49b7b10c314640f74da5fac5d Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Thu, 6 Nov 2025 14:25:14 -0500 Subject: [PATCH 1/3] feat: add trailing slash only for bare root URLs without paths or parameters Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) --- src/modules/cards/domain/value-objects/URL.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/modules/cards/domain/value-objects/URL.ts b/src/modules/cards/domain/value-objects/URL.ts index 2307012b..772ee098 100644 --- a/src/modules/cards/domain/value-objects/URL.ts +++ b/src/modules/cards/domain/value-objects/URL.ts @@ -30,8 +30,21 @@ export class URL extends ValueObject { try { // Validate URL format using the global URL constructor - new globalThis.URL(trimmedUrl); - return ok(new URL({ value: trimmedUrl })); + const parsedUrl = new globalThis.URL(trimmedUrl); + + // Add trailing slash only to truly bare root URLs + // (no path, no query parameters, no fragments) + let normalizedUrl = trimmedUrl; + if ( + (parsedUrl.pathname === '' || parsedUrl.pathname === '/') && + parsedUrl.search === '' && + parsedUrl.hash === '' && + !trimmedUrl.endsWith('/') + ) { + normalizedUrl = trimmedUrl + '/'; + } + + return ok(new URL({ value: normalizedUrl })); } catch (error) { return err(new InvalidURLError('Invalid URL format')); } -- 2.51.2 From d33f1536f57d9445fc5d40af0fedc32e2ba8e370 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Thu, 6 Nov 2025 14:27:08 -0500 Subject: [PATCH 2/3] test: add comprehensive unit tests for URL value object Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) --- .../tests/domain/value-objects/URL.test.ts | 367 ++++++++++++++++++ 1 file changed, 367 insertions(+) create mode 100644 src/modules/cards/tests/domain/value-objects/URL.test.ts diff --git a/src/modules/cards/tests/domain/value-objects/URL.test.ts b/src/modules/cards/tests/domain/value-objects/URL.test.ts new file mode 100644 index 00000000..3f0d926d --- /dev/null +++ b/src/modules/cards/tests/domain/value-objects/URL.test.ts @@ -0,0 +1,367 @@ +import { URL, InvalidURLError } from '../../../domain/value-objects/URL'; + +describe('URL Value Object', () => { + describe('create', () => { + describe('valid URLs', () => { + it('should create URL with valid http URL', () => { + const result = URL.create('http://example.com'); + + expect(result.isOk()).toBe(true); + expect(result.unwrap().value).toBe('http://example.com/'); + }); + + it('should create URL with valid https URL', () => { + const result = URL.create('https://example.com'); + + expect(result.isOk()).toBe(true); + expect(result.unwrap().value).toBe('https://example.com/'); + }); + + it('should create URL with subdomain', () => { + const result = URL.create('https://www.example.com'); + + expect(result.isOk()).toBe(true); + expect(result.unwrap().value).toBe('https://www.example.com/'); + }); + + it('should create URL with port', () => { + const result = URL.create('https://example.com:8080'); + + expect(result.isOk()).toBe(true); + expect(result.unwrap().value).toBe('https://example.com:8080/'); + }); + }); + + describe('trailing slash normalization', () => { + describe('should add trailing slash to bare root domains', () => { + it('should add trailing slash to http://example.com', () => { + const result = URL.create('http://example.com'); + + expect(result.isOk()).toBe(true); + expect(result.unwrap().value).toBe('http://example.com/'); + }); + + it('should add trailing slash to https://example.com', () => { + const result = URL.create('https://example.com'); + + expect(result.isOk()).toBe(true); + expect(result.unwrap().value).toBe('https://example.com/'); + }); + + it('should add trailing slash to https://www.example.com', () => { + const result = URL.create('https://www.example.com'); + + expect(result.isOk()).toBe(true); + expect(result.unwrap().value).toBe('https://www.example.com/'); + }); + + it('should add trailing slash to https://example.com:8080', () => { + const result = URL.create('https://example.com:8080'); + + expect(result.isOk()).toBe(true); + expect(result.unwrap().value).toBe('https://example.com:8080/'); + }); + + it('should add trailing slash to https://sub.domain.example.com', () => { + const result = URL.create('https://sub.domain.example.com'); + + expect(result.isOk()).toBe(true); + expect(result.unwrap().value).toBe('https://sub.domain.example.com/'); + }); + }); + + describe('should NOT add trailing slash when already present', () => { + it('should not add trailing slash to https://example.com/', () => { + const result = URL.create('https://example.com/'); + + expect(result.isOk()).toBe(true); + expect(result.unwrap().value).toBe('https://example.com/'); + }); + + it('should not add trailing slash to https://www.example.com/', () => { + const result = URL.create('https://www.example.com/'); + + expect(result.isOk()).toBe(true); + expect(result.unwrap().value).toBe('https://www.example.com/'); + }); + + it('should not add trailing slash to https://example.com:8080/', () => { + const result = URL.create('https://example.com:8080/'); + + expect(result.isOk()).toBe(true); + expect(result.unwrap().value).toBe('https://example.com:8080/'); + }); + }); + + describe('should NOT add trailing slash to URLs with paths', () => { + it('should not add trailing slash to https://example.com/path', () => { + const result = URL.create('https://example.com/path'); + + expect(result.isOk()).toBe(true); + expect(result.unwrap().value).toBe('https://example.com/path'); + }); + + it('should not add trailing slash to https://example.com/path/subpath', () => { + const result = URL.create('https://example.com/path/subpath'); + + expect(result.isOk()).toBe(true); + expect(result.unwrap().value).toBe( + 'https://example.com/path/subpath', + ); + }); + + it('should not add trailing slash to https://example.com/path/', () => { + const result = URL.create('https://example.com/path/'); + + expect(result.isOk()).toBe(true); + expect(result.unwrap().value).toBe('https://example.com/path/'); + }); + + it('should not add trailing slash to https://example.com/path.html', () => { + const result = URL.create('https://example.com/path.html'); + + expect(result.isOk()).toBe(true); + expect(result.unwrap().value).toBe('https://example.com/path.html'); + }); + + it('should not add trailing slash to https://example.com/api/v1/users', () => { + const result = URL.create('https://example.com/api/v1/users'); + + expect(result.isOk()).toBe(true); + expect(result.unwrap().value).toBe( + 'https://example.com/api/v1/users', + ); + }); + }); + + describe('should NOT add trailing slash to URLs with query parameters', () => { + it('should not add trailing slash to https://example.com?param=value', () => { + const result = URL.create('https://example.com?param=value'); + + expect(result.isOk()).toBe(true); + expect(result.unwrap().value).toBe('https://example.com?param=value'); + }); + + it('should not add trailing slash to https://example.com?param1=value1¶m2=value2', () => { + const result = URL.create( + 'https://example.com?param1=value1¶m2=value2', + ); + + expect(result.isOk()).toBe(true); + expect(result.unwrap().value).toBe( + 'https://example.com?param1=value1¶m2=value2', + ); + }); + + it('should not add trailing slash to https://example.com/?param=value', () => { + const result = URL.create('https://example.com/?param=value'); + + expect(result.isOk()).toBe(true); + expect(result.unwrap().value).toBe( + 'https://example.com/?param=value', + ); + }); + + it('should not add trailing slash to https://example.com/path?param=value', () => { + const result = URL.create('https://example.com/path?param=value'); + + expect(result.isOk()).toBe(true); + expect(result.unwrap().value).toBe( + 'https://example.com/path?param=value', + ); + }); + }); + + describe('should NOT add trailing slash to URLs with fragments', () => { + it('should not add trailing slash to https://example.com#section', () => { + const result = URL.create('https://example.com#section'); + + expect(result.isOk()).toBe(true); + expect(result.unwrap().value).toBe('https://example.com#section'); + }); + + it('should not add trailing slash to https://example.com/#section', () => { + const result = URL.create('https://example.com/#section'); + + expect(result.isOk()).toBe(true); + expect(result.unwrap().value).toBe('https://example.com/#section'); + }); + + it('should not add trailing slash to https://example.com/path#section', () => { + const result = URL.create('https://example.com/path#section'); + + expect(result.isOk()).toBe(true); + expect(result.unwrap().value).toBe( + 'https://example.com/path#section', + ); + }); + + it('should not add trailing slash to https://example.com?param=value#section', () => { + const result = URL.create('https://example.com?param=value#section'); + + expect(result.isOk()).toBe(true); + expect(result.unwrap().value).toBe( + 'https://example.com?param=value#section', + ); + }); + }); + + describe('should NOT add trailing slash to URLs with query parameters AND fragments', () => { + it('should not add trailing slash to https://example.com?param=value#section', () => { + const result = URL.create('https://example.com?param=value#section'); + + expect(result.isOk()).toBe(true); + expect(result.unwrap().value).toBe( + 'https://example.com?param=value#section', + ); + }); + + it('should not add trailing slash to https://example.com/path?param=value#section', () => { + const result = URL.create( + 'https://example.com/path?param=value#section', + ); + + expect(result.isOk()).toBe(true); + expect(result.unwrap().value).toBe( + 'https://example.com/path?param=value#section', + ); + }); + }); + }); + + describe('edge cases', () => { + it('should handle URLs with IP addresses', () => { + const result = URL.create('http://192.168.1.1'); + + expect(result.isOk()).toBe(true); + expect(result.unwrap().value).toBe('http://192.168.1.1/'); + }); + + it('should handle URLs with IP addresses and ports', () => { + const result = URL.create('http://192.168.1.1:8080'); + + expect(result.isOk()).toBe(true); + expect(result.unwrap().value).toBe('http://192.168.1.1:8080/'); + }); + + it('should handle localhost', () => { + const result = URL.create('http://localhost'); + + expect(result.isOk()).toBe(true); + expect(result.unwrap().value).toBe('http://localhost/'); + }); + + it('should handle localhost with port', () => { + const result = URL.create('http://localhost:3000'); + + expect(result.isOk()).toBe(true); + expect(result.unwrap().value).toBe('http://localhost:3000/'); + }); + + it('should trim whitespace before processing', () => { + const result = URL.create(' https://example.com '); + + expect(result.isOk()).toBe(true); + expect(result.unwrap().value).toBe('https://example.com/'); + }); + + it('should handle URLs with authentication', () => { + const result = URL.create('https://user:pass@example.com'); + + expect(result.isOk()).toBe(true); + expect(result.unwrap().value).toBe('https://user:pass@example.com/'); + }); + it('should handle URLs with multiple slashes', () => { + const result = URL.create('https://example.com//'); + + expect(result.isOk()).toBe(true); + expect(result.unwrap().value).toBe('https://example.com//'); + }); + }); + + describe('invalid URLs', () => { + it('should fail for empty string', () => { + const result = URL.create(''); + + if (!result.isErr()) { + throw new Error('Expected result to be an error'); + } + expect(result.isErr()).toBe(true); + expect(result.error).toBeInstanceOf(InvalidURLError); + expect(result.error.message).toBe('URL cannot be empty'); + }); + + it('should fail for whitespace only', () => { + const result = URL.create(' '); + + if (!result.isErr()) { + throw new Error('Expected result to be an error'); + } + expect(result.isErr()).toBe(true); + expect(result.error).toBeInstanceOf(InvalidURLError); + expect(result.error.message).toBe('URL cannot be empty'); + }); + + it('should fail for invalid URL format', () => { + const result = URL.create('not-a-url'); + + if (!result.isErr()) { + throw new Error('Expected result to be an error'); + } + expect(result.isErr()).toBe(true); + expect(result.error).toBeInstanceOf(InvalidURLError); + expect(result.error.message).toBe('Invalid URL format'); + }); + + it('should fail for URL without protocol', () => { + const result = URL.create('example.com'); + + if (!result.isErr()) { + throw new Error('Expected result to be an error'); + } + expect(result.isErr()).toBe(true); + expect(result.error).toBeInstanceOf(InvalidURLError); + expect(result.error.message).toBe('Invalid URL format'); + }); + + it('should fail for malformed URL', () => { + const result = URL.create('https://'); + + if (!result.isErr()) { + throw new Error('Expected result to be an error'); + } + expect(result.isErr()).toBe(true); + expect(result.error).toBeInstanceOf(InvalidURLError); + expect(result.error.message).toBe('Invalid URL format'); + }); + }); + }); + + describe('toString', () => { + it('should return the URL value as string', () => { + const url = URL.create('https://example.com/path').unwrap(); + + expect(url.toString()).toBe('https://example.com/path'); + }); + + it('should return normalized URL with trailing slash for bare domains', () => { + const url = URL.create('https://example.com').unwrap(); + + expect(url.toString()).toBe('https://example.com/'); + }); + }); + + describe('value getter', () => { + it('should return the URL value', () => { + const url = URL.create('https://example.com/path').unwrap(); + + expect(url.value).toBe('https://example.com/path'); + }); + + it('should return normalized URL with trailing slash for bare domains', () => { + const url = URL.create('https://example.com').unwrap(); + + expect(url.value).toBe('https://example.com/'); + }); + }); +}); -- 2.51.2 From 2472e104435a759bef650005f0eb16b600673a34 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Thu, 6 Nov 2025 14:34:28 -0500 Subject: [PATCH 3/3] formatting --- src/modules/cards/domain/value-objects/URL.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/modules/cards/domain/value-objects/URL.ts b/src/modules/cards/domain/value-objects/URL.ts index 772ee098..689a58fd 100644 --- a/src/modules/cards/domain/value-objects/URL.ts +++ b/src/modules/cards/domain/value-objects/URL.ts @@ -31,7 +31,7 @@ export class URL extends ValueObject { try { // Validate URL format using the global URL constructor const parsedUrl = new globalThis.URL(trimmedUrl); - + // Add trailing slash only to truly bare root URLs // (no path, no query parameters, no fragments) let normalizedUrl = trimmedUrl; @@ -43,7 +43,7 @@ export class URL extends ValueObject { ) { normalizedUrl = trimmedUrl + '/'; } - + return ok(new URL({ value: normalizedUrl })); } catch (error) { return err(new InvalidURLError('Invalid URL format')); -- 2.51.2