This repository has no description
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});