A social knowledge tool for researchers built on ATProto
1import { GetUrlStatusForMyLibraryUseCase } from '../../application/useCases/queries/GetUrlStatusForMyLibraryUseCase'; 2import { InMemoryCardRepository } from '../utils/InMemoryCardRepository'; 3import { InMemoryCollectionRepository } from '../utils/InMemoryCollectionRepository'; 4import { InMemoryCollectionQueryRepository } from '../utils/InMemoryCollectionQueryRepository'; 5import { FakeCardPublisher } from '../utils/FakeCardPublisher'; 6import { FakeCollectionPublisher } from '../utils/FakeCollectionPublisher'; 7import { FakeEventPublisher } from '../utils/FakeEventPublisher'; 8import { CuratorId } from '../../domain/value-objects/CuratorId'; 9import { CardBuilder } from '../utils/builders/CardBuilder'; 10import { CollectionBuilder } from '../utils/builders/CollectionBuilder'; 11import { CardTypeEnum } from '../../domain/value-objects/CardType'; 12import { PublishedRecordId } from '../../domain/value-objects/PublishedRecordId'; 13import { URL } from '../../domain/value-objects/URL'; 14import { err } from 'src/shared/core/Result'; 15 16describe('GetUrlStatusForMyLibraryUseCase', () => { 17 let useCase: GetUrlStatusForMyLibraryUseCase; 18 let cardRepository: InMemoryCardRepository; 19 let collectionRepository: InMemoryCollectionRepository; 20 let collectionQueryRepository: InMemoryCollectionQueryRepository; 21 let cardPublisher: FakeCardPublisher; 22 let collectionPublisher: FakeCollectionPublisher; 23 let eventPublisher: FakeEventPublisher; 24 let curatorId: CuratorId; 25 let otherCuratorId: CuratorId; 26 27 beforeEach(() => { 28 cardRepository = new InMemoryCardRepository(); 29 collectionRepository = new InMemoryCollectionRepository(); 30 collectionQueryRepository = new InMemoryCollectionQueryRepository( 31 collectionRepository, 32 ); 33 cardPublisher = new FakeCardPublisher(); 34 collectionPublisher = new FakeCollectionPublisher(); 35 eventPublisher = new FakeEventPublisher(); 36 37 useCase = new GetUrlStatusForMyLibraryUseCase( 38 cardRepository, 39 collectionQueryRepository, 40 eventPublisher, 41 ); 42 43 curatorId = CuratorId.create('did:plc:testcurator').unwrap(); 44 otherCuratorId = CuratorId.create('did:plc:othercurator').unwrap(); 45 }); 46 47 afterEach(() => { 48 cardRepository.clear(); 49 collectionRepository.clear(); 50 collectionQueryRepository.clear(); 51 cardPublisher.clear(); 52 collectionPublisher.clear(); 53 eventPublisher.clear(); 54 }); 55 56 describe('URL card in collections', () => { 57 it('should return card ID and collections when user has URL card in multiple collections', async () => { 58 const testUrl = 'https://example.com/test-article'; 59 60 // Create a URL card 61 const url = URL.create(testUrl).unwrap(); 62 const card = new CardBuilder() 63 .withCuratorId(curatorId.value) 64 .withType(CardTypeEnum.URL) 65 .withUrl(url) 66 .build(); 67 68 if (card instanceof Error) { 69 throw new Error(`Failed to create card: ${card.message}`); 70 } 71 72 // Add card to library 73 const addToLibResult = card.addToLibrary(curatorId); 74 if (addToLibResult.isErr()) { 75 throw new Error( 76 `Failed to add card to library: ${addToLibResult.error.message}`, 77 ); 78 } 79 80 await cardRepository.save(card); 81 82 // Publish the card to simulate it being published 83 cardPublisher.publishCardToLibrary(card, curatorId); 84 85 // Create first collection 86 const collection1 = new CollectionBuilder() 87 .withAuthorId(curatorId.value) 88 .withName('Tech Articles') 89 .withDescription('Collection of technology articles') 90 .build(); 91 92 if (collection1 instanceof Error) { 93 throw new Error(`Failed to create collection1: ${collection1.message}`); 94 } 95 96 // Create second collection 97 const collection2 = new CollectionBuilder() 98 .withAuthorId(curatorId.value) 99 .withName('Reading List') 100 .withDescription('My personal reading list') 101 .build(); 102 103 if (collection2 instanceof Error) { 104 throw new Error(`Failed to create collection2: ${collection2.message}`); 105 } 106 107 // Add card to both collections 108 const addToCollection1Result = collection1.addCard( 109 card.cardId, 110 curatorId, 111 ); 112 if (addToCollection1Result.isErr()) { 113 throw new Error( 114 `Failed to add card to collection1: ${addToCollection1Result.error.message}`, 115 ); 116 } 117 118 const addToCollection2Result = collection2.addCard( 119 card.cardId, 120 curatorId, 121 ); 122 if (addToCollection2Result.isErr()) { 123 throw new Error( 124 `Failed to add card to collection2: ${addToCollection2Result.error.message}`, 125 ); 126 } 127 128 // Mark collections as published 129 const collection1PublishedRecordId = PublishedRecordId.create({ 130 uri: 'at://did:plc:testcurator/network.cosmik.collection/collection1', 131 cid: 'bafyreicollection1cid', 132 }); 133 134 const collection2PublishedRecordId = PublishedRecordId.create({ 135 uri: 'at://did:plc:testcurator/network.cosmik.collection/collection2', 136 cid: 'bafyreicollection2cid', 137 }); 138 139 collection1.markAsPublished(collection1PublishedRecordId); 140 collection2.markAsPublished(collection2PublishedRecordId); 141 142 // Mark card links as published in collections 143 const cardLinkPublishedRecordId1 = PublishedRecordId.create({ 144 uri: 'at://did:plc:testcurator/network.cosmik.collection/collection1/link1', 145 cid: 'bafyreilink1cid', 146 }); 147 148 const cardLinkPublishedRecordId2 = PublishedRecordId.create({ 149 uri: 'at://did:plc:testcurator/network.cosmik.collection/collection2/link2', 150 cid: 'bafyreilink2cid', 151 }); 152 153 collection1.markCardLinkAsPublished( 154 card.cardId, 155 cardLinkPublishedRecordId1, 156 ); 157 collection2.markCardLinkAsPublished( 158 card.cardId, 159 cardLinkPublishedRecordId2, 160 ); 161 162 // Save collections 163 await collectionRepository.save(collection1); 164 await collectionRepository.save(collection2); 165 166 // Publish collections and links 167 collectionPublisher.publish(collection1); 168 collectionPublisher.publish(collection2); 169 collectionPublisher.publishCardAddedToCollection( 170 card, 171 collection1, 172 curatorId, 173 ); 174 collectionPublisher.publishCardAddedToCollection( 175 card, 176 collection2, 177 curatorId, 178 ); 179 180 // Execute the use case 181 const query = { 182 url: testUrl, 183 curatorId: curatorId.value, 184 }; 185 186 const result = await useCase.execute(query); 187 188 // Verify the result 189 expect(result.isOk()).toBe(true); 190 const response = result.unwrap(); 191 192 expect(response.cardId).toBe(card.cardId.getStringValue()); 193 expect(response.collections).toHaveLength(2); 194 195 // Verify collection details 196 const techArticlesCollection = response.collections?.find( 197 (c) => c.name === 'Tech Articles', 198 ); 199 const readingListCollection = response.collections?.find( 200 (c) => c.name === 'Reading List', 201 ); 202 203 expect(techArticlesCollection).toBeDefined(); 204 expect(techArticlesCollection?.id).toBe( 205 collection1.collectionId.getStringValue(), 206 ); 207 expect(techArticlesCollection?.uri).toBe( 208 'at://did:plc:testcurator/network.cosmik.collection/collection1', 209 ); 210 expect(techArticlesCollection?.name).toBe('Tech Articles'); 211 expect(techArticlesCollection?.description).toBe( 212 'Collection of technology articles', 213 ); 214 215 expect(readingListCollection).toBeDefined(); 216 expect(readingListCollection?.id).toBe( 217 collection2.collectionId.getStringValue(), 218 ); 219 expect(readingListCollection?.uri).toBe( 220 'at://did:plc:testcurator/network.cosmik.collection/collection2', 221 ); 222 expect(readingListCollection?.name).toBe('Reading List'); 223 expect(readingListCollection?.description).toBe( 224 'My personal reading list', 225 ); 226 }); 227 228 it('should return card ID and empty collections when user has URL card but not in any collections', async () => { 229 const testUrl = 'https://example.com/standalone-article'; 230 231 // Create a URL card 232 const url = URL.create(testUrl).unwrap(); 233 const card = new CardBuilder() 234 .withCuratorId(curatorId.value) 235 .withType(CardTypeEnum.URL) 236 .withUrl(url) 237 .build(); 238 239 if (card instanceof Error) { 240 throw new Error(`Failed to create card: ${card.message}`); 241 } 242 243 // Add card to library 244 const addToLibResult = card.addToLibrary(curatorId); 245 if (addToLibResult.isErr()) { 246 throw new Error( 247 `Failed to add card to library: ${addToLibResult.error.message}`, 248 ); 249 } 250 251 await cardRepository.save(card); 252 253 // Publish the card 254 cardPublisher.publishCardToLibrary(card, curatorId); 255 256 // Execute the use case 257 const query = { 258 url: testUrl, 259 curatorId: curatorId.value, 260 }; 261 262 const result = await useCase.execute(query); 263 264 // Verify the result 265 expect(result.isOk()).toBe(true); 266 const response = result.unwrap(); 267 268 expect(response.cardId).toBe(card.cardId.getStringValue()); 269 expect(response.collections).toHaveLength(0); 270 }); 271 272 it('should return empty result when user does not have URL card for the URL', async () => { 273 const testUrl = 'https://example.com/nonexistent-article'; 274 275 // Execute the use case without creating any cards 276 const query = { 277 url: testUrl, 278 curatorId: curatorId.value, 279 }; 280 281 const result = await useCase.execute(query); 282 283 // Verify the result 284 expect(result.isOk()).toBe(true); 285 const response = result.unwrap(); 286 287 expect(response.cardId).toBeUndefined(); 288 expect(response.collections).toBeUndefined(); 289 }); 290 291 it('should not return collections from other users even if they have the same URL', async () => { 292 const testUrl = 'https://example.com/shared-article'; 293 294 // Create URL card for first user 295 const url = URL.create(testUrl).unwrap(); 296 const card1 = new CardBuilder() 297 .withCuratorId(curatorId.value) 298 .withType(CardTypeEnum.URL) 299 .withUrl(url) 300 .build(); 301 302 if (card1 instanceof Error) { 303 throw new Error(`Failed to create card1: ${card1.message}`); 304 } 305 306 const addToLibResult1 = card1.addToLibrary(curatorId); 307 if (addToLibResult1.isErr()) { 308 throw new Error( 309 `Failed to add card1 to library: ${addToLibResult1.error.message}`, 310 ); 311 } 312 313 await cardRepository.save(card1); 314 315 // Create URL card for second user (different card, same URL) 316 const card2 = new CardBuilder() 317 .withCuratorId(otherCuratorId.value) 318 .withType(CardTypeEnum.URL) 319 .withUrl(url) 320 .build(); 321 322 if (card2 instanceof Error) { 323 throw new Error(`Failed to create card2: ${card2.message}`); 324 } 325 326 const addToLibResult2 = card2.addToLibrary(otherCuratorId); 327 if (addToLibResult2.isErr()) { 328 throw new Error( 329 `Failed to add card2 to library: ${addToLibResult2.error.message}`, 330 ); 331 } 332 333 await cardRepository.save(card2); 334 335 // Create collection for second user and add their card 336 const otherUserCollection = new CollectionBuilder() 337 .withAuthorId(otherCuratorId.value) 338 .withName('Other User Collection') 339 .build(); 340 341 if (otherUserCollection instanceof Error) { 342 throw new Error( 343 `Failed to create other user collection: ${otherUserCollection.message}`, 344 ); 345 } 346 347 const addToOtherCollectionResult = otherUserCollection.addCard( 348 card2.cardId, 349 otherCuratorId, 350 ); 351 if (addToOtherCollectionResult.isErr()) { 352 throw new Error( 353 `Failed to add card2 to other collection: ${addToOtherCollectionResult.error.message}`, 354 ); 355 } 356 357 await collectionRepository.save(otherUserCollection); 358 359 // Execute the use case for first user 360 const query = { 361 url: testUrl, 362 curatorId: curatorId.value, 363 }; 364 365 const result = await useCase.execute(query); 366 367 // Verify the result - should only see first user's card, no collections 368 expect(result.isOk()).toBe(true); 369 const response = result.unwrap(); 370 371 expect(response.cardId).toBe(card1.cardId.getStringValue()); 372 expect(response.collections).toHaveLength(0); // No collections for first user 373 }); 374 375 it('should only return collections owned by the requesting user', async () => { 376 const testUrl = 'https://example.com/multi-user-article'; 377 378 // Create URL card for the user 379 const url = URL.create(testUrl).unwrap(); 380 const card = new CardBuilder() 381 .withCuratorId(curatorId.value) 382 .withType(CardTypeEnum.URL) 383 .withUrl(url) 384 .build(); 385 386 if (card instanceof Error) { 387 throw new Error(`Failed to create card: ${card.message}`); 388 } 389 390 const addToLibResult = card.addToLibrary(curatorId); 391 if (addToLibResult.isErr()) { 392 throw new Error( 393 `Failed to add card to library: ${addToLibResult.error.message}`, 394 ); 395 } 396 397 await cardRepository.save(card); 398 399 // Create user's own collection 400 const userCollection = new CollectionBuilder() 401 .withAuthorId(curatorId.value) 402 .withName('My Collection') 403 .build(); 404 405 if (userCollection instanceof Error) { 406 throw new Error( 407 `Failed to create user collection: ${userCollection.message}`, 408 ); 409 } 410 411 const addToUserCollectionResult = userCollection.addCard( 412 card.cardId, 413 curatorId, 414 ); 415 if (addToUserCollectionResult.isErr()) { 416 throw new Error( 417 `Failed to add card to user collection: ${addToUserCollectionResult.error.message}`, 418 ); 419 } 420 421 await collectionRepository.save(userCollection); 422 423 // Create another user's collection (this should not appear in results) 424 const otherUserCollection = new CollectionBuilder() 425 .withAuthorId(otherCuratorId.value) 426 .withName('Other User Collection') 427 .build(); 428 429 if (otherUserCollection instanceof Error) { 430 throw new Error( 431 `Failed to create other user collection: ${otherUserCollection.message}`, 432 ); 433 } 434 435 // Note: We don't add the card to the other user's collection since they can't add 436 // another user's card to their collection in this domain model 437 438 await collectionRepository.save(otherUserCollection); 439 440 // Execute the use case 441 const query = { 442 url: testUrl, 443 curatorId: curatorId.value, 444 }; 445 446 const result = await useCase.execute(query); 447 448 // Verify the result - should only see user's own collection 449 expect(result.isOk()).toBe(true); 450 const response = result.unwrap(); 451 452 expect(response.cardId).toBe(card.cardId.getStringValue()); 453 expect(response.collections).toHaveLength(1); 454 expect(response.collections?.[0]?.name).toBe('My Collection'); 455 expect(response.collections?.[0]?.id).toBe( 456 userCollection.collectionId.getStringValue(), 457 ); 458 }); 459 }); 460 461 describe('Validation', () => { 462 it('should fail with invalid URL', async () => { 463 const query = { 464 url: 'not-a-valid-url', 465 curatorId: curatorId.value, 466 }; 467 468 const result = await useCase.execute(query); 469 470 expect(result.isErr()).toBe(true); 471 if (result.isErr()) { 472 expect(result.error.message).toContain('Invalid URL'); 473 } 474 }); 475 476 it('should fail with invalid curator ID', async () => { 477 const query = { 478 url: 'https://example.com/valid-url', 479 curatorId: 'invalid-curator-id', 480 }; 481 482 const result = await useCase.execute(query); 483 484 expect(result.isErr()).toBe(true); 485 if (result.isErr()) { 486 expect(result.error.message).toContain('Invalid curator ID'); 487 } 488 }); 489 }); 490 491 describe('Error handling', () => { 492 it('should handle repository errors gracefully', async () => { 493 // Create a mock repository that returns an error Result 494 const errorCardRepository = { 495 findUsersUrlCardByUrl: jest 496 .fn() 497 .mockResolvedValue(err(new Error('Database error'))), 498 save: jest.fn(), 499 findById: jest.fn(), 500 delete: jest.fn(), 501 findByUrl: jest.fn(), 502 findByCuratorId: jest.fn(), 503 findByParentCardId: jest.fn(), 504 }; 505 506 const errorUseCase = new GetUrlStatusForMyLibraryUseCase( 507 errorCardRepository, 508 collectionQueryRepository, 509 eventPublisher, 510 ); 511 512 const query = { 513 url: 'https://example.com/test-url', 514 curatorId: curatorId.value, 515 }; 516 517 const result = await errorUseCase.execute(query); 518 519 expect(result.isErr()).toBe(true); 520 if (result.isErr()) { 521 expect(result.error.message).toContain('Database error'); 522 } 523 }); 524 525 it('should handle collection query repository errors gracefully', async () => { 526 const testUrl = 'https://example.com/error-test'; 527 528 // Create a URL card 529 const url = URL.create(testUrl).unwrap(); 530 const card = new CardBuilder() 531 .withCuratorId(curatorId.value) 532 .withType(CardTypeEnum.URL) 533 .withUrl(url) 534 .build(); 535 536 if (card instanceof Error) { 537 throw new Error(`Failed to create card: ${card.message}`); 538 } 539 540 const addToLibResult = card.addToLibrary(curatorId); 541 if (addToLibResult.isErr()) { 542 throw new Error( 543 `Failed to add card to library: ${addToLibResult.error.message}`, 544 ); 545 } 546 547 await cardRepository.save(card); 548 549 // Create a mock collection query repository that throws an error 550 const errorCollectionQueryRepository = { 551 findByCreator: jest.fn(), 552 getCollectionsContainingCardForUser: jest 553 .fn() 554 .mockRejectedValue(new Error('Collection query error')), 555 getCollectionsWithUrl: jest.fn(), 556 }; 557 558 const errorUseCase = new GetUrlStatusForMyLibraryUseCase( 559 cardRepository, 560 errorCollectionQueryRepository, 561 eventPublisher, 562 ); 563 564 const query = { 565 url: testUrl, 566 curatorId: curatorId.value, 567 }; 568 569 const result = await errorUseCase.execute(query); 570 571 expect(result.isErr()).toBe(true); 572 if (result.isErr()) { 573 expect(result.error.message).toContain('Collection query error'); 574 } 575 }); 576 }); 577});