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