+15
-2
src/modules/cards/domain/value-objects/URL.ts
+15
-2
src/modules/cards/domain/value-objects/URL.ts
···
30
30
31
31
try {
32
32
// Validate URL format using the global URL constructor
33
-
new globalThis.URL(trimmedUrl);
34
-
return ok(new URL({ value: trimmedUrl }));
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 }));
35
48
} catch (error) {
36
49
return err(new InvalidURLError('Invalid URL format'));
37
50
}
+367
src/modules/cards/tests/domain/value-objects/URL.test.ts
+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¶m2=value2', () => {
146
+
const result = URL.create(
147
+
'https://example.com?param1=value1¶m2=value2',
148
+
);
149
+
150
+
expect(result.isOk()).toBe(true);
151
+
expect(result.unwrap().value).toBe(
152
+
'https://example.com?param1=value1¶m2=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
+
});