A social knowledge tool for researchers built on ATProto
1import { RemoveCardFromCollectionUseCase } from '../../application/useCases/commands/RemoveCardFromCollectionUseCase';
2import { InMemoryCardRepository } from '../utils/InMemoryCardRepository';
3import { InMemoryCollectionRepository } from '../utils/InMemoryCollectionRepository';
4import { FakeCollectionPublisher } from '../utils/FakeCollectionPublisher';
5import { CardCollectionService } from '../../domain/services/CardCollectionService';
6import { CuratorId } from '../../domain/value-objects/CuratorId';
7import { CardBuilder } from '../utils/builders/CardBuilder';
8import { CollectionBuilder } from '../utils/builders/CollectionBuilder';
9import { CardTypeEnum } from '../../domain/value-objects/CardType';
10
11describe('RemoveCardFromCollectionUseCase', () => {
12 let useCase: RemoveCardFromCollectionUseCase;
13 let cardRepository: InMemoryCardRepository;
14 let collectionRepository: InMemoryCollectionRepository;
15 let collectionPublisher: FakeCollectionPublisher;
16 let cardCollectionService: CardCollectionService;
17 let curatorId: CuratorId;
18 let otherCuratorId: CuratorId;
19
20 beforeEach(() => {
21 cardRepository = new InMemoryCardRepository();
22 collectionRepository = new InMemoryCollectionRepository();
23 collectionPublisher = new FakeCollectionPublisher();
24 cardCollectionService = new CardCollectionService(
25 collectionRepository,
26 collectionPublisher,
27 );
28
29 useCase = new RemoveCardFromCollectionUseCase(
30 cardRepository,
31 cardCollectionService,
32 );
33
34 curatorId = CuratorId.create('did:plc:testcurator').unwrap();
35 otherCuratorId = CuratorId.create('did:plc:othercurator').unwrap();
36 });
37
38 afterEach(() => {
39 cardRepository.clear();
40 collectionRepository.clear();
41 collectionPublisher.clear();
42 });
43
44 const createCard = async (type: CardTypeEnum = CardTypeEnum.URL) => {
45 const card = new CardBuilder().withType(type).build();
46
47 if (card instanceof Error) {
48 throw new Error(`Failed to create card: ${card.message}`);
49 }
50
51 await cardRepository.save(card);
52 return card;
53 };
54
55 const createCollection = async (authorId: CuratorId, name: string) => {
56 const collection = new CollectionBuilder()
57 .withAuthorId(authorId.value)
58 .withName(name)
59 .withPublished(true)
60 .build();
61
62 if (collection instanceof Error) {
63 throw new Error(`Failed to create collection: ${collection.message}`);
64 }
65
66 await collectionRepository.save(collection);
67 return collection;
68 };
69
70 const addCardToCollection = async (
71 card: any,
72 collection: any,
73 curatorId: CuratorId,
74 ) => {
75 const addResult = await cardCollectionService.addCardToCollection(
76 card,
77 collection.collectionId,
78 curatorId,
79 );
80 if (addResult.isErr()) {
81 throw new Error(
82 `Failed to add card to collection: ${addResult.error.message}`,
83 );
84 }
85 };
86
87 describe('Basic card removal from collection', () => {
88 it('should successfully remove card from collection', async () => {
89 const card = await createCard();
90 const collection = await createCollection(curatorId, 'Test Collection');
91
92 // Add card to collection first
93 await addCardToCollection(card, collection, curatorId);
94
95 const request = {
96 cardId: card.cardId.getStringValue(),
97 collectionIds: [collection.collectionId.getStringValue()],
98 curatorId: curatorId.value,
99 };
100
101 const result = await useCase.execute(request);
102
103 expect(result.isOk()).toBe(true);
104 const response = result.unwrap();
105 expect(response.cardId).toBe(card.cardId.getStringValue());
106
107 // Verify card was removed from collection
108 const removedLinks = collectionPublisher.getRemovedLinksForCollection(
109 collection.collectionId.getStringValue(),
110 );
111 expect(removedLinks).toHaveLength(1);
112 expect(removedLinks[0]?.cardId).toBe(card.cardId.getStringValue());
113 });
114
115 it('should remove card from multiple collections', async () => {
116 const card = await createCard();
117 const collection1 = await createCollection(curatorId, 'Collection 1');
118 const collection2 = await createCollection(curatorId, 'Collection 2');
119
120 // Add card to both collections
121 await addCardToCollection(card, collection1, curatorId);
122 await addCardToCollection(card, collection2, curatorId);
123
124 const request = {
125 cardId: card.cardId.getStringValue(),
126 collectionIds: [
127 collection1.collectionId.getStringValue(),
128 collection2.collectionId.getStringValue(),
129 ],
130 curatorId: curatorId.value,
131 };
132
133 const result = await useCase.execute(request);
134
135 expect(result.isOk()).toBe(true);
136
137 // Verify card was removed from both collections
138 const removedLinks1 = collectionPublisher.getRemovedLinksForCollection(
139 collection1.collectionId.getStringValue(),
140 );
141 const removedLinks2 = collectionPublisher.getRemovedLinksForCollection(
142 collection2.collectionId.getStringValue(),
143 );
144
145 expect(removedLinks1).toHaveLength(1);
146 expect(removedLinks2).toHaveLength(1);
147 expect(removedLinks1[0]?.cardId).toBe(card.cardId.getStringValue());
148 expect(removedLinks2[0]?.cardId).toBe(card.cardId.getStringValue());
149 });
150
151 it("should handle partial removal when some collections don't contain the card", async () => {
152 const card = await createCard();
153 const collection1 = await createCollection(curatorId, 'Collection 1');
154 const collection2 = await createCollection(curatorId, 'Collection 2');
155
156 // Add card to only one collection
157 await addCardToCollection(card, collection1, curatorId);
158
159 const request = {
160 cardId: card.cardId.getStringValue(),
161 collectionIds: [
162 collection1.collectionId.getStringValue(),
163 collection2.collectionId.getStringValue(),
164 ],
165 curatorId: curatorId.value,
166 };
167
168 const result = await useCase.execute(request);
169
170 expect(result.isOk()).toBe(true);
171
172 // Verify card was removed from collection1 but no error for collection2
173 const removedLinks1 = collectionPublisher.getRemovedLinksForCollection(
174 collection1.collectionId.getStringValue(),
175 );
176 const removedLinks2 = collectionPublisher.getRemovedLinksForCollection(
177 collection2.collectionId.getStringValue(),
178 );
179
180 expect(removedLinks1).toHaveLength(1);
181 expect(removedLinks2).toHaveLength(0);
182 });
183 });
184
185 describe('Authorization', () => {
186 it('should fail when trying to remove card from closed collection without permission', async () => {
187 const card = await createCard();
188 const collection = new CollectionBuilder()
189 .withAuthorId(otherCuratorId.value)
190 .withName('Closed Collection')
191 .withAccessType('CLOSED')
192 .withPublished(true)
193 .build();
194
195 if (collection instanceof Error) {
196 throw new Error(`Failed to create collection: ${collection.message}`);
197 }
198 collection.addCard(card.cardId, otherCuratorId);
199
200 await collectionRepository.save(collection);
201
202 const request = {
203 cardId: card.cardId.getStringValue(),
204 collectionIds: [collection.collectionId.getStringValue()],
205 curatorId: curatorId.value,
206 };
207
208 const result = await useCase.execute(request);
209
210 expect(result.isErr()).toBe(true);
211 if (result.isErr()) {
212 expect(result.error.message).toContain('does not have permission');
213 }
214 });
215
216 it('should allow removal from open collection by any user', async () => {
217 const card = await createCard();
218 const collection = new CollectionBuilder()
219 .withAuthorId(otherCuratorId.value)
220 .withName('Open Collection')
221 .withAccessType('OPEN')
222 .withPublished(true)
223 .build();
224
225 if (collection instanceof Error) {
226 throw new Error(`Failed to create collection: ${collection.message}`);
227 }
228
229 await collectionRepository.save(collection);
230
231 // Add card to collection first (as collection owner)
232 await addCardToCollection(card, collection, otherCuratorId);
233
234 const request = {
235 cardId: card.cardId.getStringValue(),
236 collectionIds: [collection.collectionId.getStringValue()],
237 curatorId: curatorId.value,
238 };
239
240 const result = await useCase.execute(request);
241
242 expect(result.isOk()).toBe(true);
243 });
244
245 it('should allow collection author to remove any card', async () => {
246 const card = await createCard();
247 const collection = await createCollection(
248 curatorId,
249 "Author's Collection",
250 );
251
252 await addCardToCollection(card, collection, curatorId);
253
254 const request = {
255 cardId: card.cardId.getStringValue(),
256 collectionIds: [collection.collectionId.getStringValue()],
257 curatorId: curatorId.value,
258 };
259
260 const result = await useCase.execute(request);
261
262 expect(result.isOk()).toBe(true);
263 });
264 });
265
266 describe('Validation', () => {
267 it('should fail with invalid card ID', async () => {
268 const collection = await createCollection(curatorId, 'Test Collection');
269
270 const request = {
271 cardId: 'invalid-card-id',
272 collectionIds: [collection.collectionId.getStringValue()],
273 curatorId: curatorId.value,
274 };
275
276 const result = await useCase.execute(request);
277
278 expect(result.isErr()).toBe(true);
279 if (result.isErr()) {
280 expect(result.error.message).toContain('Card not found');
281 }
282 });
283
284 it('should fail with invalid curator ID', async () => {
285 const card = await createCard();
286 const collection = await createCollection(curatorId, 'Test Collection');
287
288 const request = {
289 cardId: card.cardId.getStringValue(),
290 collectionIds: [collection.collectionId.getStringValue()],
291 curatorId: 'invalid-curator-id',
292 };
293
294 const result = await useCase.execute(request);
295
296 expect(result.isErr()).toBe(true);
297 if (result.isErr()) {
298 expect(result.error.message).toContain('Invalid curator ID');
299 }
300 });
301
302 it('should fail with invalid collection ID', async () => {
303 const card = await createCard();
304
305 const request = {
306 cardId: card.cardId.getStringValue(),
307 collectionIds: ['invalid-collection-id'],
308 curatorId: curatorId.value,
309 };
310
311 const result = await useCase.execute(request);
312
313 expect(result.isErr()).toBe(true);
314 if (result.isErr()) {
315 expect(result.error.message).toContain('Collection not found');
316 }
317 });
318
319 it('should fail when card does not exist', async () => {
320 const collection = await createCollection(curatorId, 'Test Collection');
321
322 const request = {
323 cardId: 'non-existent-card-id',
324 collectionIds: [collection.collectionId.getStringValue()],
325 curatorId: curatorId.value,
326 };
327
328 const result = await useCase.execute(request);
329
330 expect(result.isErr()).toBe(true);
331 if (result.isErr()) {
332 expect(result.error.message).toContain('Card not found');
333 }
334 });
335
336 it('should fail when collection does not exist', async () => {
337 const card = await createCard();
338
339 const request = {
340 cardId: card.cardId.getStringValue(),
341 collectionIds: ['non-existent-collection-id'],
342 curatorId: curatorId.value,
343 };
344
345 const result = await useCase.execute(request);
346
347 expect(result.isErr()).toBe(true);
348 if (result.isErr()) {
349 expect(result.error.message).toContain('Collection not found');
350 }
351 });
352
353 it('should handle empty collection IDs array', async () => {
354 const card = await createCard();
355
356 const request = {
357 cardId: card.cardId.getStringValue(),
358 collectionIds: [],
359 curatorId: curatorId.value,
360 };
361
362 const result = await useCase.execute(request);
363
364 expect(result.isOk()).toBe(true);
365
366 // No removal operations should have occurred
367 const allRemovedLinks = collectionPublisher.getAllRemovedLinks();
368 expect(allRemovedLinks).toHaveLength(0);
369 });
370 });
371
372 describe('Edge cases', () => {
373 it('should handle removing card that was never in the collection', async () => {
374 const card = await createCard();
375 const collection = await createCollection(curatorId, 'Empty Collection');
376
377 const request = {
378 cardId: card.cardId.getStringValue(),
379 collectionIds: [collection.collectionId.getStringValue()],
380 curatorId: curatorId.value,
381 };
382
383 const result = await useCase.execute(request);
384
385 expect(result.isOk()).toBe(true);
386
387 // No removal should have occurred
388 const removedLinks = collectionPublisher.getRemovedLinksForCollection(
389 collection.collectionId.getStringValue(),
390 );
391 expect(removedLinks).toHaveLength(0);
392 });
393
394 it('should handle removing card from same collection multiple times', async () => {
395 const card = await createCard();
396 const collection = await createCollection(curatorId, 'Test Collection');
397
398 await addCardToCollection(card, collection, curatorId);
399
400 const request = {
401 cardId: card.cardId.getStringValue(),
402 collectionIds: [collection.collectionId.getStringValue()],
403 curatorId: curatorId.value,
404 };
405
406 // First removal should succeed
407 const firstResult = await useCase.execute(request);
408 expect(firstResult.isOk()).toBe(true);
409
410 // Second removal should also succeed (idempotent)
411 const secondResult = await useCase.execute(request);
412 expect(secondResult.isOk()).toBe(true);
413
414 // Only one removal operation should have been recorded
415 const removedLinks = collectionPublisher.getRemovedLinksForCollection(
416 collection.collectionId.getStringValue(),
417 );
418 expect(removedLinks).toHaveLength(1);
419 });
420
421 it('should handle different card types', async () => {
422 const urlCard = await createCard(CardTypeEnum.URL);
423 const noteCard = await createCard(CardTypeEnum.NOTE);
424 const collection = await createCollection(curatorId, 'Mixed Collection');
425
426 await addCardToCollection(urlCard, collection, curatorId);
427 await addCardToCollection(noteCard, collection, curatorId);
428
429 const request = {
430 cardId: urlCard.cardId.getStringValue(),
431 collectionIds: [collection.collectionId.getStringValue()],
432 curatorId: curatorId.value,
433 };
434
435 const result = await useCase.execute(request);
436
437 expect(result.isOk()).toBe(true);
438
439 // Verify only the URL card was removed
440 const removedLinks = collectionPublisher.getRemovedLinksForCollection(
441 collection.collectionId.getStringValue(),
442 );
443 expect(removedLinks).toHaveLength(1);
444 expect(removedLinks[0]?.cardId).toBe(urlCard.cardId.getStringValue());
445 });
446
447 it('should handle repository errors gracefully', async () => {
448 const card = await createCard();
449 const collection = await createCollection(curatorId, 'Test Collection');
450
451 // Configure repository to fail
452 cardRepository.setShouldFail(true);
453
454 const request = {
455 cardId: card.cardId.getStringValue(),
456 collectionIds: [collection.collectionId.getStringValue()],
457 curatorId: curatorId.value,
458 };
459
460 const result = await useCase.execute(request);
461
462 expect(result.isErr()).toBe(true);
463 });
464 });
465});