This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

semble / src / modules / atproto / infrastructure / __tests__ / ATProtoCollectionPublisher.integration.test.ts
24 kB 644 lines
1import { ATProtoCollectionPublisher } from '../publishers/ATProtoCollectionPublisher'; 2import { FakeCardPublisher } from 'src/modules/cards/tests/utils/FakeCardPublisher'; 3import { PublishedRecordId } from 'src/modules/cards/domain/value-objects/PublishedRecordId'; 4import { CollectionBuilder } from 'src/modules/cards/tests/utils/builders/CollectionBuilder'; 5import { CardBuilder } from 'src/modules/cards/tests/utils/builders/CardBuilder'; 6import { CollectionAccessType } from 'src/modules/cards/domain/Collection'; 7import { URL } from 'src/modules/cards/domain/value-objects/URL'; 8import { UrlMetadata } from 'src/modules/cards/domain/value-objects/UrlMetadata'; 9import { CuratorId } from 'src/modules/cards/domain/value-objects/CuratorId'; 10import dotenv from 'dotenv'; 11import { AppPasswordAgentService } from './AppPasswordAgentService'; 12import { EnvironmentConfigService } from 'src/shared/infrastructure/config/EnvironmentConfigService'; 13 14// Load environment variables from .env.test 15dotenv.config({ path: '.env.test' }); 16 17// Set to false to skip unpublishing (useful for debugging published records) 18const UNPUBLISH = true; 19 20describe.skip('ATProtoCollectionPublisher', () => { 21 let collectionPublisher: ATProtoCollectionPublisher; 22 let cardPublisher: FakeCardPublisher; 23 let curatorId: CuratorId; 24 let publishedCollectionIds: PublishedRecordId[] = []; 25 let publishedLinkIds: PublishedRecordId[] = []; 26 const envConfig: EnvironmentConfigService = new EnvironmentConfigService(); 27 28 beforeAll(async () => { 29 if (!process.env.BSKY_DID || !process.env.BSKY_APP_PASSWORD) { 30 throw new Error( 31 'BSKY_DID and BSKY_APP_PASSWORD must be set in .env.test', 32 ); 33 } 34 35 const agentService = new AppPasswordAgentService({ 36 did: process.env.BSKY_DID, 37 password: process.env.BSKY_APP_PASSWORD, 38 }); 39 40 const collections = envConfig.getAtProtoConfig().collections; 41 collectionPublisher = new ATProtoCollectionPublisher( 42 agentService, 43 collections.collection, 44 collections.collectionLink, 45 collections.collectionLinkRemoval, 46 ); 47 cardPublisher = new FakeCardPublisher(); 48 curatorId = CuratorId.create(process.env.BSKY_DID).unwrap(); 49 }); 50 51 afterAll(async () => { 52 // Skip cleanup if credentials are not available or UNPUBLISH is false 53 if (!process.env.BSKY_DID || !process.env.BSKY_APP_PASSWORD || !UNPUBLISH) { 54 if (!UNPUBLISH) { 55 console.log('Skipping cleanup: UNPUBLISH is set to false'); 56 } 57 return; 58 } 59 60 // Clean up all published collections 61 for (const collectionId of publishedCollectionIds) { 62 try { 63 await collectionPublisher.unpublish(collectionId); 64 console.log(`Cleaned up collection: ${collectionId.getValue().uri}`); 65 } catch (error) { 66 console.warn(`Failed to clean up collection: ${error}`); 67 } 68 } 69 70 // Clean up all published collection links 71 for (const linkId of publishedLinkIds) { 72 try { 73 await collectionPublisher.unpublishCardAddedToCollection(linkId); 74 console.log(`Cleaned up collection link: ${linkId.getValue().uri}`); 75 } catch (error) { 76 console.warn(`Failed to clean up collection link: ${error}`); 77 } 78 } 79 80 // Clear fake card publisher 81 cardPublisher.clear(); 82 }); 83 84 describe('Collection Publishing', () => { 85 it('should publish and unpublish an empty collection', async () => { 86 // Skip test if credentials are not available 87 if (!process.env.BSKY_DID || !process.env.BSKY_APP_PASSWORD) { 88 console.warn('Skipping test: BSKY credentials not found in .env.test'); 89 return; 90 } 91 92 // Create an empty collection 93 const collection = new CollectionBuilder() 94 .withAuthorId(curatorId.value) 95 .withName('Test Collection') 96 .withDescription('A test collection for publishing') 97 .withAccessType(CollectionAccessType.OPEN) 98 .buildOrThrow(); 99 100 // 1. Publish the collection 101 const publishResult = await collectionPublisher.publish(collection); 102 expect(publishResult.isOk()).toBe(true); 103 104 if (publishResult.isOk()) { 105 const collectionRecordId = publishResult.value; 106 publishedCollectionIds.push(collectionRecordId); 107 108 console.log( 109 `Published empty collection: ${collectionRecordId.getValue().uri}`, 110 ); 111 112 // Mark collection as published 113 collection.markAsPublished(collectionRecordId); 114 expect(collection.isPublished).toBe(true); 115 116 // 2. Test updating the collection 117 const updateResult = await collectionPublisher.publish(collection); 118 expect(updateResult.isOk()).toBe(true); 119 120 if (updateResult.isOk()) { 121 expect(updateResult.value).toBe(collectionRecordId); 122 console.log( 123 `Updated collection: ${collectionRecordId.getValue().uri}`, 124 ); 125 } 126 127 // 3. Unpublish the collection 128 if (UNPUBLISH) { 129 const unpublishResult = 130 await collectionPublisher.unpublish(collectionRecordId); 131 expect(unpublishResult.isOk()).toBe(true); 132 133 console.log('Successfully unpublished empty collection'); 134 135 // Remove from cleanup list since we've already unpublished it 136 publishedCollectionIds = publishedCollectionIds.filter( 137 (id) => id !== collectionRecordId, 138 ); 139 } else { 140 console.log('Skipping unpublish: UNPUBLISH is set to false'); 141 } 142 } 143 }, 15000); 144 145 it('should publish a collection with cards and collection links', async () => { 146 // Skip test if credentials are not available 147 if (!process.env.BSKY_DID || !process.env.BSKY_APP_PASSWORD) { 148 console.warn('Skipping test: BSKY credentials not found in .env.test'); 149 return; 150 } 151 152 // 1. Create and publish some cards first 153 const testUrl1 = URL.create('https://example.com/article1').unwrap(); 154 const metadata1 = UrlMetadata.create({ 155 url: testUrl1.value, 156 title: 'Test Article 1', 157 description: 'First test article', 158 retrievedAt: new Date(), 159 }).unwrap(); 160 161 const card1 = new CardBuilder() 162 .withCuratorId(curatorId.value) 163 .withUrlCard(testUrl1, metadata1) 164 .withUrl(testUrl1) 165 .withPublishedRecordId({ 166 uri: 'at://did:plc:original/network.cosmik.card/original1', 167 cid: 'bafyoriginal1', 168 }) 169 .buildOrThrow(); 170 171 const testUrl2 = URL.create('https://example.com/article2').unwrap(); 172 const metadata2 = UrlMetadata.create({ 173 url: testUrl2.value, 174 title: 'Test Article 2', 175 description: 'Second test article', 176 retrievedAt: new Date(), 177 }).unwrap(); 178 179 const card2 = new CardBuilder() 180 .withCuratorId(curatorId.value) 181 .withUrlCard(testUrl2, metadata2) 182 .withUrl(testUrl2) 183 .withPublishedRecordId({ 184 uri: 'at://did:plc:original/network.cosmik.card/original2', 185 cid: 'bafyoriginal2', 186 }) 187 .buildOrThrow(); 188 189 // Add cards to curator's library and publish them 190 card1.addToLibrary(curatorId); 191 card2.addToLibrary(curatorId); 192 193 const card1PublishResult = await cardPublisher.publishCardToLibrary( 194 card1, 195 curatorId, 196 ); 197 expect(card1PublishResult.isOk()).toBe(true); 198 const card1RecordId = card1PublishResult.unwrap(); 199 card1.markCardInLibraryAsPublished(curatorId, card1RecordId); 200 201 const card2PublishResult = await cardPublisher.publishCardToLibrary( 202 card2, 203 curatorId, 204 ); 205 expect(card2PublishResult.isOk()).toBe(true); 206 const card2RecordId = card2PublishResult.unwrap(); 207 card2.markCardInLibraryAsPublished(curatorId, card2RecordId); 208 209 console.log( 210 `Published cards: ${card1RecordId.getValue().uri}, ${card2RecordId.getValue().uri}`, 211 ); 212 213 // 2. Create a collection and add the cards 214 const collection = new CollectionBuilder() 215 .withAuthorId(curatorId.value) 216 .withName('Test Collection with Cards') 217 .withDescription('A collection containing test cards') 218 .withAccessType(CollectionAccessType.OPEN) 219 .buildOrThrow(); 220 221 // Add cards to collection 222 const addCard1Result = collection.addCard(card1.cardId, curatorId); 223 expect(addCard1Result.isOk()).toBe(true); 224 225 const addCard2Result = collection.addCard(card2.cardId, curatorId); 226 expect(addCard2Result.isOk()).toBe(true); 227 228 // 3. Publish the collection (collection record only) 229 const publishResult = await collectionPublisher.publish(collection); 230 expect(publishResult.isOk()).toBe(true); 231 232 if (publishResult.isOk()) { 233 const collectionRecordId = publishResult.value; 234 publishedCollectionIds.push(collectionRecordId); 235 236 console.log( 237 `Published collection: ${collectionRecordId.getValue().uri}`, 238 ); 239 240 // Mark collection as published 241 collection.markAsPublished(collectionRecordId); 242 243 // 4. Publish the card links separately 244 const link1PublishResult = 245 await collectionPublisher.publishCardAddedToCollection( 246 card1, 247 collection, 248 curatorId, 249 ); 250 expect(link1PublishResult.isOk()).toBe(true); 251 const link1RecordId = link1PublishResult.unwrap(); 252 publishedLinkIds.push(link1RecordId); 253 collection.markCardLinkAsPublished(card1.cardId, link1RecordId); 254 255 const link2PublishResult = 256 await collectionPublisher.publishCardAddedToCollection( 257 card2, 258 collection, 259 curatorId, 260 ); 261 expect(link2PublishResult.isOk()).toBe(true); 262 const link2RecordId = link2PublishResult.unwrap(); 263 publishedLinkIds.push(link2RecordId); 264 collection.markCardLinkAsPublished(card2.cardId, link2RecordId); 265 266 console.log( 267 `Published card links: ${link1RecordId.getValue().uri}, ${link2RecordId.getValue().uri}`, 268 ); 269 270 // 5. Test adding another card to the existing collection 271 const testUrl3 = URL.create('https://example.com/article3').unwrap(); 272 const metadata3 = UrlMetadata.create({ 273 url: testUrl3.value, 274 title: 'Test Article 3', 275 description: 'Third test article', 276 retrievedAt: new Date(), 277 }).unwrap(); 278 279 const card3 = new CardBuilder() 280 .withCuratorId(curatorId.value) 281 .withUrlCard(testUrl3, metadata3) 282 .withUrl(testUrl3) 283 .withPublishedRecordId({ 284 uri: 'at://did:plc:original/network.cosmik.card/original3', 285 cid: 'bafyoriginal3', 286 }) 287 .buildOrThrow(); 288 289 card3.addToLibrary(curatorId); 290 const card3PublishResult = await cardPublisher.publishCardToLibrary( 291 card3, 292 curatorId, 293 ); 294 expect(card3PublishResult.isOk()).toBe(true); 295 const card3RecordId = card3PublishResult.unwrap(); 296 card3.markCardInLibraryAsPublished(curatorId, card3RecordId); 297 298 // Add the new card to the collection 299 const addCard3Result = collection.addCard(card3.cardId, curatorId); 300 expect(addCard3Result.isOk()).toBe(true); 301 302 // Publish the new card link 303 const link3PublishResult = 304 await collectionPublisher.publishCardAddedToCollection( 305 card3, 306 collection, 307 curatorId, 308 ); 309 expect(link3PublishResult.isOk()).toBe(true); 310 const link3RecordId = link3PublishResult.unwrap(); 311 publishedLinkIds.push(link3RecordId); 312 313 console.log( 314 `Published additional card link for card 3: ${link3RecordId.getValue().uri}`, 315 ); 316 317 // 6. Unpublish the collection 318 if (UNPUBLISH) { 319 const unpublishResult = 320 await collectionPublisher.unpublish(collectionRecordId); 321 expect(unpublishResult.isOk()).toBe(true); 322 323 console.log('Successfully unpublished collection with cards'); 324 325 // Remove from cleanup list since we've already unpublished it 326 publishedCollectionIds = publishedCollectionIds.filter( 327 (id) => id !== collectionRecordId, 328 ); 329 } else { 330 console.log('Skipping unpublish: UNPUBLISH is set to false'); 331 } 332 } 333 }, 30000); 334 335 it('should publish a collection with a card shared between users', async () => { 336 // Skip test if credentials are not available 337 if (!process.env.BSKY_DID || !process.env.BSKY_APP_PASSWORD) { 338 console.warn('Skipping test: BSKY credentials not found in .env.test'); 339 return; 340 } 341 342 // Create two different users (we'll simulate userB with the same credentials for testing) 343 const userA = CuratorId.create('did:plc:userA').unwrap(); 344 const userB = curatorId; // Use our test credentials as userB 345 346 // 1. User A creates and publishes a note card to their library 347 const originalNote = new CardBuilder() 348 .withCuratorId(userA.value) 349 .withNoteCard('This is an original note created by User A') 350 .withPublishedRecordId({ 351 uri: 'at://did:plc:userA/network.cosmik.card/original-note', 352 cid: 'bafyuserAnote123', 353 }) 354 .buildOrThrow(); 355 356 // Simulate User A adding to their own library and publishing 357 originalNote.addToLibrary(userA); 358 const userALibraryRecordId = PublishedRecordId.create({ 359 uri: 'at://did:plc:userA/network.cosmik.card/userA-library-note', 360 cid: 'bafyuserAlib123', 361 }); 362 originalNote.markCardInLibraryAsPublished(userA, userALibraryRecordId); 363 364 console.log( 365 `Simulated User A publishing note: ${originalNote.publishedRecordId?.getValue().uri}`, 366 ); 367 368 // 2. User B adds the same card to their library 369 const addToLibraryResult = originalNote.addToLibrary(userB); 370 expect(addToLibraryResult.isOk()).toBe(true); 371 372 // User B publishes the card to their library (this creates a new record in User B's repo) 373 const userBPublishResult = await cardPublisher.publishCardToLibrary( 374 originalNote, 375 userB, 376 ); 377 expect(userBPublishResult.isOk()).toBe(true); 378 const userBLibraryRecordId = userBPublishResult.unwrap(); 379 originalNote.markCardInLibraryAsPublished(userB, userBLibraryRecordId); 380 381 console.log( 382 `User B published note to their library: ${userBLibraryRecordId.getValue().uri}`, 383 ); 384 385 // 3. User B creates a collection 386 const userBCollection = new CollectionBuilder() 387 .withAuthorId(userB.value) 388 .withName('User B Collection with Shared Card') 389 .withDescription( 390 'Collection containing a card originally created by User A', 391 ) 392 .withAccessType(CollectionAccessType.OPEN) 393 .buildOrThrow(); 394 395 // 4. User B adds the card to their collection 396 const addCardResult = userBCollection.addCard(originalNote.cardId, userB); 397 expect(addCardResult.isOk()).toBe(true); 398 399 // 5. Publish User B's collection 400 const collectionPublishResult = 401 await collectionPublisher.publish(userBCollection); 402 expect(collectionPublishResult.isOk()).toBe(true); 403 404 if (collectionPublishResult.isOk()) { 405 const collectionRecordId = collectionPublishResult.value; 406 publishedCollectionIds.push(collectionRecordId); 407 userBCollection.markAsPublished(collectionRecordId); 408 409 console.log( 410 `Published User B's collection: ${collectionRecordId.getValue().uri}`, 411 ); 412 413 // 6. Publish the collection link (this should reference both User B's copy and User A's original) 414 const linkPublishResult = 415 await collectionPublisher.publishCardAddedToCollection( 416 originalNote, 417 userBCollection, 418 userB, 419 ); 420 expect(linkPublishResult.isOk()).toBe(true); 421 422 if (linkPublishResult.isOk()) { 423 const linkRecordId = linkPublishResult.value; 424 publishedLinkIds.push(linkRecordId); 425 userBCollection.markCardLinkAsPublished( 426 originalNote.cardId, 427 linkRecordId, 428 ); 429 430 console.log( 431 `Published collection link with cross-user card reference: ${linkRecordId.getValue().uri}`, 432 ); 433 434 // Verify the card has different library memberships 435 expect(originalNote.libraryMembershipCount).toBe(2); 436 expect(originalNote.isInLibrary(userA)).toBe(true); 437 expect(originalNote.isInLibrary(userB)).toBe(true); 438 439 // Verify User B's library membership has a different published record ID than the original 440 const userBMembership = originalNote.getLibraryInfo(userB); 441 expect(userBMembership).toBeDefined(); 442 expect(userBMembership!.publishedRecordId).toBeDefined(); 443 expect(userBMembership!.publishedRecordId!.uri).not.toBe( 444 originalNote.publishedRecordId!.uri, 445 ); 446 447 console.log( 448 `Verified cross-user card sharing: Original=${originalNote.publishedRecordId!.uri}, UserB=${userBMembership!.publishedRecordId!.uri}`, 449 ); 450 } 451 452 // 7. Clean up 453 if (UNPUBLISH) { 454 const unpublishResult = 455 await collectionPublisher.unpublish(collectionRecordId); 456 expect(unpublishResult.isOk()).toBe(true); 457 458 console.log('Successfully unpublished collection with shared card'); 459 460 publishedCollectionIds = publishedCollectionIds.filter( 461 (id) => id !== collectionRecordId, 462 ); 463 } else { 464 console.log('Skipping unpublish: UNPUBLISH is set to false'); 465 } 466 } 467 }, 30000); 468 }); 469 470 describe.skip('Closed Collection Publishing', () => { 471 it('should publish a closed collection with collaborators', async () => { 472 // Skip test if credentials are not available 473 if (!process.env.BSKY_DID || !process.env.BSKY_APP_PASSWORD) { 474 console.warn('Skipping test: BSKY credentials not found in .env.test'); 475 return; 476 } 477 478 const collaboratorDid = 'did:plc:collaborator123'; // Mock collaborator 479 480 // Create a closed collection with collaborators 481 const collection = new CollectionBuilder() 482 .withAuthorId(curatorId.value) 483 .withName('Closed Test Collection') 484 .withDescription('A closed collection for testing') 485 .withAccessType(CollectionAccessType.CLOSED) 486 .withCollaborators([collaboratorDid]) 487 .buildOrThrow(); 488 489 // 1. Publish the collection 490 const publishResult = await collectionPublisher.publish(collection); 491 expect(publishResult.isOk()).toBe(true); 492 493 if (publishResult.isOk()) { 494 const collectionRecordId = publishResult.value; 495 publishedCollectionIds.push(collectionRecordId); 496 497 console.log( 498 `Published closed collection: ${collectionRecordId.getValue().uri}`, 499 ); 500 501 // Mark collection as published 502 collection.markAsPublished(collectionRecordId); 503 504 // Verify collection properties 505 expect(collection.accessType).toBe(CollectionAccessType.CLOSED); 506 expect(collection.collaboratorIds).toHaveLength(1); 507 expect(collection.collaboratorIds[0]?.value).toBe(collaboratorDid); 508 509 // 2. Unpublish the collection 510 if (UNPUBLISH) { 511 const unpublishResult = 512 await collectionPublisher.unpublish(collectionRecordId); 513 expect(unpublishResult.isOk()).toBe(true); 514 515 console.log('Successfully unpublished closed collection'); 516 517 // Remove from cleanup list since we've already unpublished it 518 publishedCollectionIds = publishedCollectionIds.filter( 519 (id) => id !== collectionRecordId, 520 ); 521 } else { 522 console.log('Skipping unpublish: UNPUBLISH is set to false'); 523 } 524 } 525 }, 15000); 526 }); 527 528 describe('Error Handling', () => { 529 it('should handle authentication errors gracefully', async () => { 530 // Create a publisher with invalid credentials 531 const invalidAgentService = new AppPasswordAgentService({ 532 did: 'did:plc:invalid', 533 password: 'invalid-password', 534 }); 535 536 const collections = envConfig.getAtProtoConfig().collections; 537 const invalidPublisher = new ATProtoCollectionPublisher( 538 invalidAgentService, 539 collections.collection, 540 collections.collectionLink, 541 collections.collectionLinkRemoval, 542 ); 543 544 const testCollection = new CollectionBuilder() 545 .withAuthorId('did:plc:invalid') 546 .withName('Test Collection') 547 .withAccessType(CollectionAccessType.OPEN) 548 .buildOrThrow(); 549 550 const result = await invalidPublisher.publish(testCollection); 551 expect(result.isErr()).toBe(true); 552 553 if (result.isErr()) { 554 expect(result.error.message).toMatch(/authentication|error/i); 555 } 556 }, 10000); 557 558 it('should handle invalid record IDs for unpublishing', async () => { 559 // Skip test if credentials are not available 560 if (!process.env.BSKY_DID || !process.env.BSKY_APP_PASSWORD) { 561 console.warn('Skipping test: BSKY credentials not found in .env.test'); 562 return; 563 } 564 565 const invalidRecordId = PublishedRecordId.create({ 566 uri: 'at://did:plc:invalid/network.cosmik.collection/invalid', 567 cid: 'bafyinvalid', 568 }); 569 570 const result = await collectionPublisher.unpublish(invalidRecordId); 571 expect(result.isErr()).toBe(true); 572 }, 10000); 573 574 it('should handle collections with unpublished cards gracefully', async () => { 575 // Skip test if credentials are not available 576 if (!process.env.BSKY_DID || !process.env.BSKY_APP_PASSWORD) { 577 console.warn('Skipping test: BSKY credentials not found in .env.test'); 578 return; 579 } 580 581 // Create a collection 582 const collection = new CollectionBuilder() 583 .withAuthorId(curatorId.value) 584 .withName('Collection with Unpublished Card') 585 .withAccessType(CollectionAccessType.OPEN) 586 .buildOrThrow(); 587 588 // Create an unpublished card 589 const unpublishedCard = new CardBuilder() 590 .withCuratorId(curatorId.value) 591 .withNoteCard('Unpublished note') 592 .buildOrThrow(); 593 594 // Add unpublished card to collection 595 const addCardResult = collection.addCard( 596 unpublishedCard.cardId, 597 curatorId, 598 ); 599 expect(addCardResult.isOk()).toBe(true); 600 601 // Try to publish the collection (should succeed) 602 const publishResult = await collectionPublisher.publish(collection); 603 expect(publishResult.isOk()).toBe(true); 604 605 if (publishResult.isOk()) { 606 const collectionRecordId = publishResult.value; 607 publishedCollectionIds.push(collectionRecordId); 608 609 console.log( 610 `Published collection: ${collectionRecordId.getValue().uri}`, 611 ); 612 613 collection.markAsPublished(collectionRecordId); 614 615 // Try to publish the card link (should fail because card is not published) 616 const linkPublishResult = 617 await collectionPublisher.publishCardAddedToCollection( 618 unpublishedCard, 619 collection, 620 curatorId, 621 ); 622 expect(linkPublishResult.isErr()).toBe(true); 623 624 if (linkPublishResult.isErr()) { 625 expect(linkPublishResult.error.message).toMatch( 626 /published in curator's library/i, 627 ); 628 } 629 630 // Clean up 631 if (UNPUBLISH) { 632 const unpublishResult = 633 await collectionPublisher.unpublish(collectionRecordId); 634 expect(unpublishResult.isOk()).toBe(true); 635 publishedCollectionIds = publishedCollectionIds.filter( 636 (id) => id !== collectionRecordId, 637 ); 638 } else { 639 console.log('Skipping unpublish: UNPUBLISH is set to false'); 640 } 641 } 642 }, 15000); 643 }); 644});