A social knowledge tool for researchers built on ATProto

Merge branch 'development' of https://github.com/cosmik-network/semble into development

Changed files
+382 -2
src
modules
cards
domain
value-objects
tests
domain
value-objects
+15 -2
src/modules/cards/domain/value-objects/URL.ts
··· 30 31 try { 32 // Validate URL format using the global URL constructor 33 - new globalThis.URL(trimmedUrl); 34 - return ok(new URL({ value: trimmedUrl })); 35 } catch (error) { 36 return err(new InvalidURLError('Invalid URL format')); 37 }
··· 30 31 try { 32 // Validate URL format using the global URL constructor 33 + const parsedUrl = new globalThis.URL(trimmedUrl); 34 + 35 + // Add trailing slash only to truly bare root URLs 36 + // (no path, no query parameters, no fragments) 37 + let normalizedUrl = trimmedUrl; 38 + if ( 39 + (parsedUrl.pathname === '' || parsedUrl.pathname === '/') && 40 + parsedUrl.search === '' && 41 + parsedUrl.hash === '' && 42 + !trimmedUrl.endsWith('/') 43 + ) { 44 + normalizedUrl = trimmedUrl + '/'; 45 + } 46 + 47 + return ok(new URL({ value: normalizedUrl })); 48 } catch (error) { 49 return err(new InvalidURLError('Invalid URL format')); 50 }
+367
src/modules/cards/tests/domain/value-objects/URL.test.ts
···
··· 1 + import { URL, InvalidURLError } from '../../../domain/value-objects/URL'; 2 + 3 + describe('URL Value Object', () => { 4 + describe('create', () => { 5 + describe('valid URLs', () => { 6 + it('should create URL with valid http URL', () => { 7 + const result = URL.create('http://example.com'); 8 + 9 + expect(result.isOk()).toBe(true); 10 + expect(result.unwrap().value).toBe('http://example.com/'); 11 + }); 12 + 13 + it('should create URL with valid https URL', () => { 14 + const result = URL.create('https://example.com'); 15 + 16 + expect(result.isOk()).toBe(true); 17 + expect(result.unwrap().value).toBe('https://example.com/'); 18 + }); 19 + 20 + it('should create URL with subdomain', () => { 21 + const result = URL.create('https://www.example.com'); 22 + 23 + expect(result.isOk()).toBe(true); 24 + expect(result.unwrap().value).toBe('https://www.example.com/'); 25 + }); 26 + 27 + it('should create URL with port', () => { 28 + const result = URL.create('https://example.com:8080'); 29 + 30 + expect(result.isOk()).toBe(true); 31 + expect(result.unwrap().value).toBe('https://example.com:8080/'); 32 + }); 33 + }); 34 + 35 + describe('trailing slash normalization', () => { 36 + describe('should add trailing slash to bare root domains', () => { 37 + it('should add trailing slash to http://example.com', () => { 38 + const result = URL.create('http://example.com'); 39 + 40 + expect(result.isOk()).toBe(true); 41 + expect(result.unwrap().value).toBe('http://example.com/'); 42 + }); 43 + 44 + it('should add trailing slash to https://example.com', () => { 45 + const result = URL.create('https://example.com'); 46 + 47 + expect(result.isOk()).toBe(true); 48 + expect(result.unwrap().value).toBe('https://example.com/'); 49 + }); 50 + 51 + it('should add trailing slash to https://www.example.com', () => { 52 + const result = URL.create('https://www.example.com'); 53 + 54 + expect(result.isOk()).toBe(true); 55 + expect(result.unwrap().value).toBe('https://www.example.com/'); 56 + }); 57 + 58 + it('should add trailing slash to https://example.com:8080', () => { 59 + const result = URL.create('https://example.com:8080'); 60 + 61 + expect(result.isOk()).toBe(true); 62 + expect(result.unwrap().value).toBe('https://example.com:8080/'); 63 + }); 64 + 65 + it('should add trailing slash to https://sub.domain.example.com', () => { 66 + const result = URL.create('https://sub.domain.example.com'); 67 + 68 + expect(result.isOk()).toBe(true); 69 + expect(result.unwrap().value).toBe('https://sub.domain.example.com/'); 70 + }); 71 + }); 72 + 73 + describe('should NOT add trailing slash when already present', () => { 74 + it('should not add trailing slash to https://example.com/', () => { 75 + const result = URL.create('https://example.com/'); 76 + 77 + expect(result.isOk()).toBe(true); 78 + expect(result.unwrap().value).toBe('https://example.com/'); 79 + }); 80 + 81 + it('should not add trailing slash to https://www.example.com/', () => { 82 + const result = URL.create('https://www.example.com/'); 83 + 84 + expect(result.isOk()).toBe(true); 85 + expect(result.unwrap().value).toBe('https://www.example.com/'); 86 + }); 87 + 88 + it('should not add trailing slash to https://example.com:8080/', () => { 89 + const result = URL.create('https://example.com:8080/'); 90 + 91 + expect(result.isOk()).toBe(true); 92 + expect(result.unwrap().value).toBe('https://example.com:8080/'); 93 + }); 94 + }); 95 + 96 + describe('should NOT add trailing slash to URLs with paths', () => { 97 + it('should not add trailing slash to https://example.com/path', () => { 98 + const result = URL.create('https://example.com/path'); 99 + 100 + expect(result.isOk()).toBe(true); 101 + expect(result.unwrap().value).toBe('https://example.com/path'); 102 + }); 103 + 104 + it('should not add trailing slash to https://example.com/path/subpath', () => { 105 + const result = URL.create('https://example.com/path/subpath'); 106 + 107 + expect(result.isOk()).toBe(true); 108 + expect(result.unwrap().value).toBe( 109 + 'https://example.com/path/subpath', 110 + ); 111 + }); 112 + 113 + it('should not add trailing slash to https://example.com/path/', () => { 114 + const result = URL.create('https://example.com/path/'); 115 + 116 + expect(result.isOk()).toBe(true); 117 + expect(result.unwrap().value).toBe('https://example.com/path/'); 118 + }); 119 + 120 + it('should not add trailing slash to https://example.com/path.html', () => { 121 + const result = URL.create('https://example.com/path.html'); 122 + 123 + expect(result.isOk()).toBe(true); 124 + expect(result.unwrap().value).toBe('https://example.com/path.html'); 125 + }); 126 + 127 + it('should not add trailing slash to https://example.com/api/v1/users', () => { 128 + const result = URL.create('https://example.com/api/v1/users'); 129 + 130 + expect(result.isOk()).toBe(true); 131 + expect(result.unwrap().value).toBe( 132 + 'https://example.com/api/v1/users', 133 + ); 134 + }); 135 + }); 136 + 137 + describe('should NOT add trailing slash to URLs with query parameters', () => { 138 + it('should not add trailing slash to https://example.com?param=value', () => { 139 + const result = URL.create('https://example.com?param=value'); 140 + 141 + expect(result.isOk()).toBe(true); 142 + expect(result.unwrap().value).toBe('https://example.com?param=value'); 143 + }); 144 + 145 + it('should not add trailing slash to https://example.com?param1=value1&param2=value2', () => { 146 + const result = URL.create( 147 + 'https://example.com?param1=value1&param2=value2', 148 + ); 149 + 150 + expect(result.isOk()).toBe(true); 151 + expect(result.unwrap().value).toBe( 152 + 'https://example.com?param1=value1&param2=value2', 153 + ); 154 + }); 155 + 156 + it('should not add trailing slash to https://example.com/?param=value', () => { 157 + const result = URL.create('https://example.com/?param=value'); 158 + 159 + expect(result.isOk()).toBe(true); 160 + expect(result.unwrap().value).toBe( 161 + 'https://example.com/?param=value', 162 + ); 163 + }); 164 + 165 + it('should not add trailing slash to https://example.com/path?param=value', () => { 166 + const result = URL.create('https://example.com/path?param=value'); 167 + 168 + expect(result.isOk()).toBe(true); 169 + expect(result.unwrap().value).toBe( 170 + 'https://example.com/path?param=value', 171 + ); 172 + }); 173 + }); 174 + 175 + describe('should NOT add trailing slash to URLs with fragments', () => { 176 + it('should not add trailing slash to https://example.com#section', () => { 177 + const result = URL.create('https://example.com#section'); 178 + 179 + expect(result.isOk()).toBe(true); 180 + expect(result.unwrap().value).toBe('https://example.com#section'); 181 + }); 182 + 183 + it('should not add trailing slash to https://example.com/#section', () => { 184 + const result = URL.create('https://example.com/#section'); 185 + 186 + expect(result.isOk()).toBe(true); 187 + expect(result.unwrap().value).toBe('https://example.com/#section'); 188 + }); 189 + 190 + it('should not add trailing slash to https://example.com/path#section', () => { 191 + const result = URL.create('https://example.com/path#section'); 192 + 193 + expect(result.isOk()).toBe(true); 194 + expect(result.unwrap().value).toBe( 195 + 'https://example.com/path#section', 196 + ); 197 + }); 198 + 199 + it('should not add trailing slash to https://example.com?param=value#section', () => { 200 + const result = URL.create('https://example.com?param=value#section'); 201 + 202 + expect(result.isOk()).toBe(true); 203 + expect(result.unwrap().value).toBe( 204 + 'https://example.com?param=value#section', 205 + ); 206 + }); 207 + }); 208 + 209 + describe('should NOT add trailing slash to URLs with query parameters AND fragments', () => { 210 + it('should not add trailing slash to https://example.com?param=value#section', () => { 211 + const result = URL.create('https://example.com?param=value#section'); 212 + 213 + expect(result.isOk()).toBe(true); 214 + expect(result.unwrap().value).toBe( 215 + 'https://example.com?param=value#section', 216 + ); 217 + }); 218 + 219 + it('should not add trailing slash to https://example.com/path?param=value#section', () => { 220 + const result = URL.create( 221 + 'https://example.com/path?param=value#section', 222 + ); 223 + 224 + expect(result.isOk()).toBe(true); 225 + expect(result.unwrap().value).toBe( 226 + 'https://example.com/path?param=value#section', 227 + ); 228 + }); 229 + }); 230 + }); 231 + 232 + describe('edge cases', () => { 233 + it('should handle URLs with IP addresses', () => { 234 + const result = URL.create('http://192.168.1.1'); 235 + 236 + expect(result.isOk()).toBe(true); 237 + expect(result.unwrap().value).toBe('http://192.168.1.1/'); 238 + }); 239 + 240 + it('should handle URLs with IP addresses and ports', () => { 241 + const result = URL.create('http://192.168.1.1:8080'); 242 + 243 + expect(result.isOk()).toBe(true); 244 + expect(result.unwrap().value).toBe('http://192.168.1.1:8080/'); 245 + }); 246 + 247 + it('should handle localhost', () => { 248 + const result = URL.create('http://localhost'); 249 + 250 + expect(result.isOk()).toBe(true); 251 + expect(result.unwrap().value).toBe('http://localhost/'); 252 + }); 253 + 254 + it('should handle localhost with port', () => { 255 + const result = URL.create('http://localhost:3000'); 256 + 257 + expect(result.isOk()).toBe(true); 258 + expect(result.unwrap().value).toBe('http://localhost:3000/'); 259 + }); 260 + 261 + it('should trim whitespace before processing', () => { 262 + const result = URL.create(' https://example.com '); 263 + 264 + expect(result.isOk()).toBe(true); 265 + expect(result.unwrap().value).toBe('https://example.com/'); 266 + }); 267 + 268 + it('should handle URLs with authentication', () => { 269 + const result = URL.create('https://user:pass@example.com'); 270 + 271 + expect(result.isOk()).toBe(true); 272 + expect(result.unwrap().value).toBe('https://user:pass@example.com/'); 273 + }); 274 + it('should handle URLs with multiple slashes', () => { 275 + const result = URL.create('https://example.com//'); 276 + 277 + expect(result.isOk()).toBe(true); 278 + expect(result.unwrap().value).toBe('https://example.com//'); 279 + }); 280 + }); 281 + 282 + describe('invalid URLs', () => { 283 + it('should fail for empty string', () => { 284 + const result = URL.create(''); 285 + 286 + if (!result.isErr()) { 287 + throw new Error('Expected result to be an error'); 288 + } 289 + expect(result.isErr()).toBe(true); 290 + expect(result.error).toBeInstanceOf(InvalidURLError); 291 + expect(result.error.message).toBe('URL cannot be empty'); 292 + }); 293 + 294 + it('should fail for whitespace only', () => { 295 + const result = URL.create(' '); 296 + 297 + if (!result.isErr()) { 298 + throw new Error('Expected result to be an error'); 299 + } 300 + expect(result.isErr()).toBe(true); 301 + expect(result.error).toBeInstanceOf(InvalidURLError); 302 + expect(result.error.message).toBe('URL cannot be empty'); 303 + }); 304 + 305 + it('should fail for invalid URL format', () => { 306 + const result = URL.create('not-a-url'); 307 + 308 + if (!result.isErr()) { 309 + throw new Error('Expected result to be an error'); 310 + } 311 + expect(result.isErr()).toBe(true); 312 + expect(result.error).toBeInstanceOf(InvalidURLError); 313 + expect(result.error.message).toBe('Invalid URL format'); 314 + }); 315 + 316 + it('should fail for URL without protocol', () => { 317 + const result = URL.create('example.com'); 318 + 319 + if (!result.isErr()) { 320 + throw new Error('Expected result to be an error'); 321 + } 322 + expect(result.isErr()).toBe(true); 323 + expect(result.error).toBeInstanceOf(InvalidURLError); 324 + expect(result.error.message).toBe('Invalid URL format'); 325 + }); 326 + 327 + it('should fail for malformed URL', () => { 328 + const result = URL.create('https://'); 329 + 330 + if (!result.isErr()) { 331 + throw new Error('Expected result to be an error'); 332 + } 333 + expect(result.isErr()).toBe(true); 334 + expect(result.error).toBeInstanceOf(InvalidURLError); 335 + expect(result.error.message).toBe('Invalid URL format'); 336 + }); 337 + }); 338 + }); 339 + 340 + describe('toString', () => { 341 + it('should return the URL value as string', () => { 342 + const url = URL.create('https://example.com/path').unwrap(); 343 + 344 + expect(url.toString()).toBe('https://example.com/path'); 345 + }); 346 + 347 + it('should return normalized URL with trailing slash for bare domains', () => { 348 + const url = URL.create('https://example.com').unwrap(); 349 + 350 + expect(url.toString()).toBe('https://example.com/'); 351 + }); 352 + }); 353 + 354 + describe('value getter', () => { 355 + it('should return the URL value', () => { 356 + const url = URL.create('https://example.com/path').unwrap(); 357 + 358 + expect(url.value).toBe('https://example.com/path'); 359 + }); 360 + 361 + it('should return normalized URL with trailing slash for bare domains', () => { 362 + const url = URL.create('https://example.com').unwrap(); 363 + 364 + expect(url.value).toBe('https://example.com/'); 365 + }); 366 + }); 367 + });