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});