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