A social knowledge tool for researchers built on ATProto
1import { UpdateCollectionUseCase } from '../../application/useCases/commands/UpdateCollectionUseCase';
2import { InMemoryCollectionRepository } from '../utils/InMemoryCollectionRepository';
3import { FakeCollectionPublisher } from '../utils/FakeCollectionPublisher';
4import { CuratorId } from '../../domain/value-objects/CuratorId';
5import { CollectionBuilder } from '../utils/builders/CollectionBuilder';
6import { Err } from 'src/shared/core/Result';
7
8describe('UpdateCollectionUseCase', () => {
9 let useCase: UpdateCollectionUseCase;
10 let collectionRepository: InMemoryCollectionRepository;
11 let collectionPublisher: FakeCollectionPublisher;
12 let curatorId: CuratorId;
13 let otherCuratorId: CuratorId;
14
15 beforeEach(() => {
16 collectionRepository = new InMemoryCollectionRepository();
17 collectionPublisher = new FakeCollectionPublisher();
18 useCase = new UpdateCollectionUseCase(
19 collectionRepository,
20 collectionPublisher,
21 );
22
23 curatorId = CuratorId.create('did:plc:testcurator').unwrap();
24 otherCuratorId = CuratorId.create('did:plc:othercurator').unwrap();
25 });
26
27 afterEach(() => {
28 collectionRepository.clear();
29 collectionPublisher.clear();
30 });
31
32 const createCollection = async (
33 authorId: CuratorId,
34 name: string,
35 description?: string,
36 ) => {
37 const collection = new CollectionBuilder()
38 .withAuthorId(authorId.value)
39 .withName(name)
40 .withDescription(description || '')
41 .withPublished(true)
42 .build();
43
44 if (collection instanceof Error) {
45 throw new Error(`Failed to create collection: ${collection.message}`);
46 }
47
48 await collectionRepository.save(collection);
49 return collection;
50 };
51
52 describe('Basic collection update', () => {
53 it('should successfully update collection name and description', async () => {
54 const collection = await createCollection(
55 curatorId,
56 'Original Name',
57 'Original description',
58 );
59
60 const request = {
61 collectionId: collection.collectionId.getStringValue(),
62 name: 'Updated Name',
63 description: 'Updated description',
64 curatorId: curatorId.value,
65 };
66
67 const result = await useCase.execute(request);
68
69 expect(result.isOk()).toBe(true);
70 const response = result.unwrap();
71 expect(response.collectionId).toBe(
72 collection.collectionId.getStringValue(),
73 );
74
75 // Verify collection was updated
76 const updatedCollectionResult = await collectionRepository.findById(
77 collection.collectionId,
78 );
79 const updatedCollection = updatedCollectionResult.unwrap()!;
80 expect(updatedCollection.name.value).toBe('Updated Name');
81 expect(updatedCollection.description?.value).toBe('Updated description');
82 expect(updatedCollection.updatedAt.getTime()).toBeGreaterThanOrEqual(
83 collection.createdAt.getTime(),
84 );
85 });
86
87 it('should update collection name only', async () => {
88 const collection = await createCollection(
89 curatorId,
90 'Original Name',
91 'Original description',
92 );
93
94 const request = {
95 collectionId: collection.collectionId.getStringValue(),
96 name: 'New Name Only',
97 description: 'Original description', // Keep original description
98 curatorId: curatorId.value,
99 };
100
101 const result = await useCase.execute(request);
102
103 expect(result.isOk()).toBe(true);
104
105 // Verify only name was updated
106 const updatedCollectionResult = await collectionRepository.findById(
107 collection.collectionId,
108 );
109 const updatedCollection = updatedCollectionResult.unwrap()!;
110 expect(updatedCollection.name.value).toBe('New Name Only');
111 expect(updatedCollection.description?.value).toBe('Original description');
112 });
113
114 it('should clear description when not provided', async () => {
115 const collection = await createCollection(
116 curatorId,
117 'Original Name',
118 'Original description',
119 );
120
121 const request = {
122 collectionId: collection.collectionId.getStringValue(),
123 name: 'Updated Name',
124 curatorId: curatorId.value,
125 };
126
127 const result = await useCase.execute(request);
128
129 expect(result.isOk()).toBe(true);
130
131 // Verify description was cleared
132 const updatedCollectionResult = await collectionRepository.findById(
133 collection.collectionId,
134 );
135 const updatedCollection = updatedCollectionResult.unwrap()!;
136 expect(updatedCollection.name.value).toBe('Updated Name');
137 expect(updatedCollection.description).toBeUndefined();
138 });
139
140 it('should preserve other collection properties', async () => {
141 const collection = await createCollection(
142 curatorId,
143 'Original Name',
144 'Original description',
145 );
146 const originalCreatedAt = collection.createdAt;
147 const originalAccessType = collection.accessType;
148
149 const request = {
150 collectionId: collection.collectionId.getStringValue(),
151 name: 'Updated Name',
152 description: 'Updated description',
153 curatorId: curatorId.value,
154 };
155
156 const result = await useCase.execute(request);
157
158 expect(result.isOk()).toBe(true);
159
160 // Verify other properties are preserved
161 const updatedCollectionResult = await collectionRepository.findById(
162 collection.collectionId,
163 );
164 const updatedCollection = updatedCollectionResult.unwrap()!;
165 expect(updatedCollection.collectionId.getStringValue()).toBe(
166 collection.collectionId.getStringValue(),
167 );
168 expect(updatedCollection.authorId.equals(curatorId)).toBe(true);
169 expect(updatedCollection.createdAt).toEqual(originalCreatedAt);
170 expect(updatedCollection.accessType).toBe(originalAccessType);
171 });
172 });
173
174 describe('Authorization', () => {
175 it("should fail when trying to update another user's collection", async () => {
176 const collection = await createCollection(curatorId, 'Original Name');
177
178 const request = {
179 collectionId: collection.collectionId.getStringValue(),
180 name: 'Unauthorized Update',
181 curatorId: otherCuratorId.value,
182 };
183
184 const result = await useCase.execute(request);
185
186 expect(result.isErr()).toBe(true);
187 if (result.isErr()) {
188 expect(result.error.message).toContain(
189 'Only the collection author can update the collection',
190 );
191 }
192
193 // Verify original collection was not modified
194 const originalCollectionResult = await collectionRepository.findById(
195 collection.collectionId,
196 );
197 const originalCollection = originalCollectionResult.unwrap()!;
198 expect(originalCollection.name.value).toBe('Original Name');
199 });
200
201 it('should allow author to update their own collection', async () => {
202 const collection = await createCollection(curatorId, 'Original Name');
203
204 const request = {
205 collectionId: collection.collectionId.getStringValue(),
206 name: 'Author Update',
207 curatorId: curatorId.value,
208 };
209
210 const result = await useCase.execute(request);
211
212 expect(result.isOk()).toBe(true);
213
214 // Verify update was successful
215 const updatedCollectionResult = await collectionRepository.findById(
216 collection.collectionId,
217 );
218 const updatedCollection = updatedCollectionResult.unwrap()!;
219 expect(updatedCollection.name.value).toBe('Author Update');
220 });
221 });
222
223 describe('Validation', () => {
224 it('should fail with invalid collection ID', async () => {
225 const request = {
226 collectionId: 'invalid-collection-id',
227 name: 'Updated Name',
228 curatorId: curatorId.value,
229 };
230
231 const result = await useCase.execute(request);
232
233 expect(result.isErr()).toBe(true);
234 if (result.isErr()) {
235 expect(result.error.message).toContain('Collection not found');
236 }
237 });
238
239 it('should fail with invalid curator ID', async () => {
240 const collection = await createCollection(curatorId, 'Original Name');
241
242 const request = {
243 collectionId: collection.collectionId.getStringValue(),
244 name: 'Updated Name',
245 curatorId: 'invalid-curator-id',
246 };
247
248 const result = await useCase.execute(request);
249
250 expect(result.isErr()).toBe(true);
251 if (result.isErr()) {
252 expect(result.error.message).toContain('Invalid curator ID');
253 }
254 });
255
256 it('should fail when collection does not exist', async () => {
257 const request = {
258 collectionId: 'non-existent-collection-id',
259 name: 'Updated Name',
260 curatorId: curatorId.value,
261 };
262
263 const result = await useCase.execute(request);
264
265 expect(result.isErr()).toBe(true);
266 if (result.isErr()) {
267 expect(result.error.message).toContain('Collection not found');
268 }
269 });
270
271 it('should fail with empty collection name', async () => {
272 const collection = await createCollection(curatorId, 'Original Name');
273
274 const request = {
275 collectionId: collection.collectionId.getStringValue(),
276 name: '',
277 curatorId: curatorId.value,
278 };
279
280 const result = await useCase.execute(request);
281
282 expect(result.isErr()).toBe(true);
283 if (result.isErr()) {
284 expect(result.error.message).toContain(
285 'Collection name cannot be empty',
286 );
287 }
288 });
289
290 it('should fail with collection name that is too long', async () => {
291 const collection = await createCollection(curatorId, 'Original Name');
292
293 const request = {
294 collectionId: collection.collectionId.getStringValue(),
295 name: 'a'.repeat(101), // Exceeds MAX_LENGTH
296 curatorId: curatorId.value,
297 };
298
299 const result = await useCase.execute(request);
300
301 expect(result.isErr()).toBe(true);
302 if (result.isErr()) {
303 expect(result.error.message).toContain('Collection name cannot exceed');
304 }
305 });
306
307 it('should fail with description that is too long', async () => {
308 const collection = await createCollection(curatorId, 'Original Name');
309
310 const request = {
311 collectionId: collection.collectionId.getStringValue(),
312 name: 'Valid Name',
313 description: 'a'.repeat(501), // Exceeds MAX_LENGTH
314 curatorId: curatorId.value,
315 };
316
317 const result = await useCase.execute(request);
318
319 expect(result.isErr()).toBe(true);
320 if (result.isErr()) {
321 expect(result.error.message).toContain(
322 'Collection description cannot exceed',
323 );
324 }
325 });
326
327 it('should trim whitespace from name and description', async () => {
328 const collection = await createCollection(curatorId, 'Original Name');
329
330 const request = {
331 collectionId: collection.collectionId.getStringValue(),
332 name: ' Updated Name ',
333 description: ' Updated description ',
334 curatorId: curatorId.value,
335 };
336
337 const result = await useCase.execute(request);
338
339 expect(result.isOk()).toBe(true);
340
341 // Verify whitespace was trimmed
342 const updatedCollectionResult = await collectionRepository.findById(
343 collection.collectionId,
344 );
345 const updatedCollection = updatedCollectionResult.unwrap()!;
346 expect(updatedCollection.name.value).toBe('Updated Name');
347 expect(updatedCollection.description?.value).toBe('Updated description');
348 });
349 });
350
351 describe('Publishing integration', () => {
352 it('should republish collection if it was already published', async () => {
353 const collection = await createCollection(curatorId, 'Original Name');
354 const initialPublishCount =
355 collectionPublisher.getPublishedCollections().length;
356
357 const request = {
358 collectionId: collection.collectionId.getStringValue(),
359 name: 'Updated Name',
360 curatorId: curatorId.value,
361 };
362
363 const result = await useCase.execute(request);
364
365 expect(result.isOk()).toBe(true);
366
367 // Verify republishing occurred
368 const finalPublishCount =
369 collectionPublisher.getPublishedCollections().length;
370 expect(finalPublishCount).toBeGreaterThanOrEqual(initialPublishCount);
371
372 // Verify published record ID was updated
373 const updatedCollectionResult = await collectionRepository.findById(
374 collection.collectionId,
375 );
376 const updatedCollection = updatedCollectionResult.unwrap()!;
377 expect(updatedCollection.isPublished).toBe(true);
378 expect(updatedCollection.publishedRecordId).toBeDefined();
379 });
380
381 it('should not republish unpublished collection', async () => {
382 const collection = new CollectionBuilder()
383 .withAuthorId(curatorId.value)
384 .withName('Unpublished Collection')
385 .withPublished(false)
386 .build();
387
388 if (collection instanceof Error) {
389 throw new Error(`Failed to create collection: ${collection.message}`);
390 }
391
392 await collectionRepository.save(collection);
393 const initialPublishCount =
394 collectionPublisher.getPublishedCollections().length;
395
396 const request = {
397 collectionId: collection.collectionId.getStringValue(),
398 name: 'Updated Unpublished',
399 curatorId: curatorId.value,
400 };
401
402 const result = await useCase.execute(request);
403
404 expect(result.isOk()).toBe(true);
405
406 // Verify no additional publishing occurred
407 const finalPublishCount =
408 collectionPublisher.getPublishedCollections().length;
409 expect(finalPublishCount).toBe(initialPublishCount);
410 });
411
412 it('should handle republishing failure gracefully', async () => {
413 const collection = await createCollection(curatorId, 'Original Name');
414
415 // Configure publisher to fail
416 collectionPublisher.setShouldFail(true);
417
418 const request = {
419 collectionId: collection.collectionId.getStringValue(),
420 name: 'Updated Name',
421 curatorId: curatorId.value,
422 };
423
424 const result = await useCase.execute(request);
425
426 expect(result.isErr()).toBe(true);
427 if (result.isErr()) {
428 expect(result.error.message).toContain(
429 'Failed to republish collection',
430 );
431 }
432 });
433 });
434
435 describe('Edge cases', () => {
436 it('should handle updating to the same name and description', async () => {
437 const collection = await createCollection(
438 curatorId,
439 'Same Name',
440 'Same description',
441 );
442
443 const request = {
444 collectionId: collection.collectionId.getStringValue(),
445 name: 'Same Name',
446 description: 'Same description',
447 curatorId: curatorId.value,
448 };
449
450 const result = await useCase.execute(request);
451
452 expect(result.isOk()).toBe(true);
453
454 // Verify collection was still updated (updatedAt should change)
455 const updatedCollectionResult = await collectionRepository.findById(
456 collection.collectionId,
457 );
458 const updatedCollection = updatedCollectionResult.unwrap()!;
459 expect(updatedCollection.name.value).toBe('Same Name');
460 expect(updatedCollection.description?.value).toBe('Same description');
461 expect(updatedCollection.updatedAt.getTime()).toBeGreaterThanOrEqual(
462 collection.createdAt.getTime(),
463 );
464 });
465
466 it('should handle maximum length name and description', async () => {
467 const collection = await createCollection(curatorId, 'Original Name');
468
469 const request = {
470 collectionId: collection.collectionId.getStringValue(),
471 name: 'a'.repeat(100), // Exactly MAX_LENGTH
472 description: 'b'.repeat(500), // Exactly MAX_LENGTH
473 curatorId: curatorId.value,
474 };
475
476 const result = await useCase.execute(request);
477
478 expect(result.isOk()).toBe(true);
479
480 // Verify values were saved correctly
481 const updatedCollectionResult = await collectionRepository.findById(
482 collection.collectionId,
483 );
484 const updatedCollection = updatedCollectionResult.unwrap()!;
485 expect(updatedCollection.name.value.length).toBe(100);
486 expect(updatedCollection.description?.value.length).toBe(500);
487 });
488 });
489});