A social knowledge tool for researchers built on ATProto
1import {
2 PostgreSqlContainer,
3 StartedPostgreSqlContainer,
4} from '@testcontainers/postgresql';
5import postgres from 'postgres';
6import { drizzle, PostgresJsDatabase } from 'drizzle-orm/postgres-js';
7import { DrizzleCardQueryRepository } from '../../infrastructure/repositories/DrizzleCardQueryRepository';
8import { DrizzleCardRepository } from '../../infrastructure/repositories/DrizzleCardRepository';
9import { DrizzleCollectionRepository } from '../../infrastructure/repositories/DrizzleCollectionRepository';
10import { CuratorId } from '../../domain/value-objects/CuratorId';
11import { UniqueEntityID } from '../../../../shared/domain/UniqueEntityID';
12import { cards } from '../../infrastructure/repositories/schema/card.sql';
13import {
14 collections,
15 collectionCards,
16} from '../../infrastructure/repositories/schema/collection.sql';
17import { libraryMemberships } from '../../infrastructure/repositories/schema/libraryMembership.sql';
18import { publishedRecords } from '../../infrastructure/repositories/schema/publishedRecord.sql';
19import { Collection, CollectionAccessType } from '../../domain/Collection';
20import { CardBuilder } from '../utils/builders/CardBuilder';
21import { URL } from '../../domain/value-objects/URL';
22import { UrlMetadata } from '../../domain/value-objects/UrlMetadata';
23import { CardSortField, SortOrder } from '../../domain/ICardQueryRepository';
24import { createTestSchema } from '../test-utils/createTestSchema';
25import { CardTypeEnum } from '../../domain/value-objects/CardType';
26
27describe('DrizzleCardQueryRepository - getCardsInCollection', () => {
28 let container: StartedPostgreSqlContainer;
29 let db: PostgresJsDatabase;
30 let queryRepository: DrizzleCardQueryRepository;
31 let cardRepository: DrizzleCardRepository;
32 let collectionRepository: DrizzleCollectionRepository;
33
34 // Test data
35 let curatorId: CuratorId;
36 let otherCuratorId: CuratorId;
37 let thirdCuratorId: CuratorId;
38
39 // Setup before all tests
40 beforeAll(async () => {
41 // Start PostgreSQL container
42 container = await new PostgreSqlContainer('postgres:14').start();
43
44 // Create database connection
45 const connectionString = container.getConnectionUri();
46 process.env.DATABASE_URL = connectionString;
47 const client = postgres(connectionString);
48 db = drizzle(client);
49
50 // Create repositories
51 queryRepository = new DrizzleCardQueryRepository(db);
52 cardRepository = new DrizzleCardRepository(db);
53 collectionRepository = new DrizzleCollectionRepository(db);
54
55 // Create schema using helper function
56 await createTestSchema(db);
57
58 // Create test data
59 curatorId = CuratorId.create('did:plc:testcurator').unwrap();
60 otherCuratorId = CuratorId.create('did:plc:othercurator').unwrap();
61 thirdCuratorId = CuratorId.create('did:plc:thirdcurator').unwrap();
62 }, 60000); // Increase timeout for container startup
63
64 // Cleanup after all tests
65 afterAll(async () => {
66 // Stop container
67 await container.stop();
68 });
69
70 // Clear data between tests
71 beforeEach(async () => {
72 await db.delete(collectionCards);
73 await db.delete(collections);
74 await db.delete(libraryMemberships);
75 await db.delete(cards);
76 await db.delete(publishedRecords);
77 });
78
79 describe('getCardsInCollection', () => {
80 it('should return empty result when collection has no URL cards', async () => {
81 // Create empty collection
82 const collection = Collection.create(
83 {
84 authorId: curatorId,
85 name: 'Empty Collection',
86 accessType: CollectionAccessType.OPEN,
87 collaboratorIds: [],
88 createdAt: new Date(),
89 updatedAt: new Date(),
90 },
91 new UniqueEntityID(),
92 ).unwrap();
93
94 await collectionRepository.save(collection);
95
96 const result = await queryRepository.getCardsInCollection(
97 collection.collectionId.getStringValue(),
98 {
99 page: 1,
100 limit: 10,
101 sortBy: CardSortField.UPDATED_AT,
102 sortOrder: SortOrder.DESC,
103 },
104 );
105
106 expect(result.items).toHaveLength(0);
107 expect(result.totalCount).toBe(0);
108 expect(result.hasMore).toBe(false);
109 });
110
111 it('should return URL cards in a collection', async () => {
112 // Create URL cards
113 const url1 = URL.create('https://example.com/article1').unwrap();
114 const urlMetadata1 = UrlMetadata.create({
115 url: url1.value,
116 title: 'Collection Article 1',
117 description: 'First article in collection',
118 author: 'John Doe',
119 imageUrl: 'https://example.com/image1.jpg',
120 }).unwrap();
121
122 const urlCard1 = new CardBuilder()
123 .withCuratorId(curatorId.value)
124 .withUrlCard(url1, urlMetadata1)
125 .withCreatedAt(new Date('2023-01-01'))
126 .withUpdatedAt(new Date('2023-01-01'))
127 .buildOrThrow();
128
129 const url2 = URL.create('https://example.com/article2').unwrap();
130 const urlCard2 = new CardBuilder()
131 .withCuratorId(curatorId.value)
132 .withUrlCard(url2)
133 .withCreatedAt(new Date('2023-01-02'))
134 .withUpdatedAt(new Date('2023-01-02'))
135 .buildOrThrow();
136
137 // Save cards
138 await cardRepository.save(urlCard1);
139 await cardRepository.save(urlCard2);
140
141 // Create collection and add cards
142 const collection = Collection.create(
143 {
144 authorId: curatorId,
145 name: 'Test Collection',
146 accessType: CollectionAccessType.OPEN,
147 collaboratorIds: [],
148 createdAt: new Date(),
149 updatedAt: new Date(),
150 },
151 new UniqueEntityID(),
152 ).unwrap();
153
154 collection.addCard(urlCard1.cardId, curatorId);
155 collection.addCard(urlCard2.cardId, curatorId);
156
157 await collectionRepository.save(collection);
158
159 // Query cards in collection
160 const result = await queryRepository.getCardsInCollection(
161 collection.collectionId.getStringValue(),
162 {
163 page: 1,
164 limit: 10,
165 sortBy: CardSortField.UPDATED_AT,
166 sortOrder: SortOrder.DESC,
167 },
168 );
169
170 expect(result.items).toHaveLength(2);
171 expect(result.totalCount).toBe(2);
172 expect(result.hasMore).toBe(false);
173
174 // Check URL card data
175 const card1Result = result.items.find((item) => item.url === url1.value);
176 const card2Result = result.items.find((item) => item.url === url2.value);
177
178 expect(card1Result).toBeDefined();
179 expect(card1Result?.type).toBe(CardTypeEnum.URL);
180 expect(card1Result?.cardContent.title).toBe('Collection Article 1');
181 expect(card1Result?.cardContent.description).toBe(
182 'First article in collection',
183 );
184 expect(card1Result?.cardContent.author).toBe('John Doe');
185 expect(card1Result?.cardContent.thumbnailUrl).toBe(
186 'https://example.com/image1.jpg',
187 );
188
189 expect(card2Result).toBeDefined();
190 expect(card2Result?.type).toBe(CardTypeEnum.URL);
191 expect(card2Result?.cardContent.title).toBeUndefined(); // No metadata provided
192 });
193
194 it('should only include notes by collection author, not notes by other users', async () => {
195 // Create URL card
196 const url = URL.create('https://example.com/shared-article').unwrap();
197 const urlCard = new CardBuilder()
198 .withCuratorId(curatorId.value)
199 .withUrlCard(url)
200 .buildOrThrow();
201
202 urlCard.addToLibrary(curatorId);
203
204 await cardRepository.save(urlCard);
205
206 // Create note by collection author
207 const authorNote = new CardBuilder()
208 .withCuratorId(curatorId.value)
209 .withNoteCard('Note by collection author')
210 .withParentCard(urlCard.cardId)
211 .buildOrThrow();
212
213 authorNote.addToLibrary(curatorId);
214
215 await cardRepository.save(authorNote);
216
217 // Create note by different user on the same URL card
218 const otherUserNote = new CardBuilder()
219 .withCuratorId(otherCuratorId.value)
220 .withNoteCard('Note by other user')
221 .withParentCard(urlCard.cardId)
222 .buildOrThrow();
223
224 otherUserNote.addToLibrary(otherCuratorId);
225
226 await cardRepository.save(otherUserNote);
227
228 // Create collection authored by curatorId and add URL card
229 const collection = Collection.create(
230 {
231 authorId: curatorId,
232 name: 'Collection by First User',
233 accessType: CollectionAccessType.OPEN,
234 collaboratorIds: [],
235 createdAt: new Date(),
236 updatedAt: new Date(),
237 },
238 new UniqueEntityID(),
239 ).unwrap();
240
241 collection.addCard(urlCard.cardId, curatorId);
242 await collectionRepository.save(collection);
243
244 // Query cards in collection
245 const result = await queryRepository.getCardsInCollection(
246 collection.collectionId.getStringValue(),
247 {
248 page: 1,
249 limit: 10,
250 sortBy: CardSortField.UPDATED_AT,
251 sortOrder: SortOrder.DESC,
252 },
253 );
254
255 expect(result.items).toHaveLength(1);
256 const urlCardResult = result.items[0];
257
258 // Should only include the note by the collection author, not the other user's note
259 expect(urlCardResult?.note).toBeDefined();
260 expect(urlCardResult?.note?.id).toBe(authorNote.cardId.getStringValue());
261 expect(urlCardResult?.note?.text).toBe('Note by collection author');
262 });
263
264 it('should not include notes when only other users have notes, not collection author', async () => {
265 // Create URL card
266 const url = URL.create(
267 'https://example.com/article-with-other-notes',
268 ).unwrap();
269 const urlCard = new CardBuilder()
270 .withCuratorId(curatorId.value)
271 .withUrlCard(url)
272 .buildOrThrow();
273
274 urlCard.addToLibrary(curatorId);
275
276 await cardRepository.save(urlCard);
277
278 // Create note by different user on the URL card (NO note by collection author)
279 const otherUserNote = new CardBuilder()
280 .withCuratorId(otherCuratorId.value)
281 .withNoteCard('Note by other user only')
282 .withParentCard(urlCard.cardId)
283 .buildOrThrow();
284
285 otherUserNote.addToLibrary(otherCuratorId);
286
287 await cardRepository.save(otherUserNote);
288
289 // Create collection authored by curatorId and add URL card
290 const collection = Collection.create(
291 {
292 authorId: curatorId,
293 name: 'Collection with Other User Notes Only',
294 accessType: CollectionAccessType.OPEN,
295 collaboratorIds: [],
296 createdAt: new Date(),
297 updatedAt: new Date(),
298 },
299 new UniqueEntityID(),
300 ).unwrap();
301
302 collection.addCard(urlCard.cardId, curatorId);
303 await collectionRepository.save(collection);
304
305 // Query cards in collection
306 const result = await queryRepository.getCardsInCollection(
307 collection.collectionId.getStringValue(),
308 {
309 page: 1,
310 limit: 10,
311 sortBy: CardSortField.UPDATED_AT,
312 sortOrder: SortOrder.DESC,
313 },
314 );
315
316 expect(result.items).toHaveLength(1);
317 const urlCardResult = result.items[0];
318
319 // Should not include any note since only other users have notes, not the collection author
320 expect(urlCardResult?.note).toBeUndefined();
321 });
322
323 it('should not include note cards that are not connected to collection URL cards', async () => {
324 // Create URL card in collection
325 const url = URL.create('https://example.com/collection-article').unwrap();
326 const urlCard = new CardBuilder()
327 .withCuratorId(curatorId.value)
328 .withUrlCard(url)
329 .buildOrThrow();
330
331 await cardRepository.save(urlCard);
332
333 // Create another URL card NOT in collection
334 const otherUrl = URL.create('https://example.com/other-article').unwrap();
335 const otherUrlCard = new CardBuilder()
336 .withCuratorId(curatorId.value)
337 .withUrlCard(otherUrl)
338 .buildOrThrow();
339
340 await cardRepository.save(otherUrlCard);
341
342 // Create note card connected to the OTHER URL card (not in collection)
343 const noteCard = new CardBuilder()
344 .withCuratorId(curatorId.value)
345 .withNoteCard('This note is for the other article')
346 .withParentCard(otherUrlCard.cardId)
347 .buildOrThrow();
348
349 await cardRepository.save(noteCard);
350
351 // Create collection and add only the first URL card
352 const collection = Collection.create(
353 {
354 authorId: curatorId,
355 name: 'Selective Collection',
356 accessType: CollectionAccessType.OPEN,
357 collaboratorIds: [],
358 createdAt: new Date(),
359 updatedAt: new Date(),
360 },
361 new UniqueEntityID(),
362 ).unwrap();
363
364 collection.addCard(urlCard.cardId, curatorId);
365 await collectionRepository.save(collection);
366
367 // Query cards in collection
368 const result = await queryRepository.getCardsInCollection(
369 collection.collectionId.getStringValue(),
370 {
371 page: 1,
372 limit: 10,
373 sortBy: CardSortField.UPDATED_AT,
374 sortOrder: SortOrder.DESC,
375 },
376 );
377
378 expect(result.items).toHaveLength(1);
379 const urlCardResult = result.items[0];
380
381 // Should not have a note since the note is connected to a different URL card
382 expect(urlCardResult?.note).toBeUndefined();
383 });
384
385 it('should handle collection with cards from multiple users', async () => {
386 // Create URL cards from different users
387 const url1 = URL.create('https://example.com/user1-article').unwrap();
388 const urlCard1 = new CardBuilder()
389 .withCuratorId(curatorId.value)
390 .withUrlCard(url1)
391 .buildOrThrow();
392
393 const url2 = URL.create('https://example.com/user2-article').unwrap();
394 const urlCard2 = new CardBuilder()
395 .withCuratorId(otherCuratorId.value)
396 .withUrlCard(url2)
397 .buildOrThrow();
398
399 await cardRepository.save(urlCard1);
400 await cardRepository.save(urlCard2);
401
402 // Create collection and add both cards
403 const collection = Collection.create(
404 {
405 authorId: curatorId,
406 name: 'Multi-User Collection',
407 accessType: CollectionAccessType.OPEN,
408 collaboratorIds: [],
409 createdAt: new Date(),
410 updatedAt: new Date(),
411 },
412 new UniqueEntityID(),
413 ).unwrap();
414
415 collection.addCard(urlCard1.cardId, curatorId);
416 collection.addCard(urlCard2.cardId, curatorId);
417 await collectionRepository.save(collection);
418
419 // Query cards in collection
420 const result = await queryRepository.getCardsInCollection(
421 collection.collectionId.getStringValue(),
422 {
423 page: 1,
424 limit: 10,
425 sortBy: CardSortField.UPDATED_AT,
426 sortOrder: SortOrder.DESC,
427 },
428 );
429
430 expect(result.items).toHaveLength(2);
431
432 const urls = result.items.map((item) => item.url);
433 expect(urls).toContain(url1.value);
434 expect(urls).toContain(url2.value);
435 });
436
437 it('should not return note cards directly from collection', async () => {
438 // Create a standalone note card
439 const noteCard = new CardBuilder()
440 .withCuratorId(curatorId.value)
441 .withNoteCard('Standalone note in collection')
442 .buildOrThrow();
443
444 await cardRepository.save(noteCard);
445
446 // Create collection and add note card directly
447 const collection = Collection.create(
448 {
449 authorId: curatorId,
450 name: 'Collection with Direct Note',
451 accessType: CollectionAccessType.OPEN,
452 collaboratorIds: [],
453 createdAt: new Date(),
454 updatedAt: new Date(),
455 },
456 new UniqueEntityID(),
457 ).unwrap();
458
459 collection.addCard(noteCard.cardId, curatorId);
460 await collectionRepository.save(collection);
461
462 // Query cards in collection
463 const result = await queryRepository.getCardsInCollection(
464 collection.collectionId.getStringValue(),
465 {
466 page: 1,
467 limit: 10,
468 sortBy: CardSortField.UPDATED_AT,
469 sortOrder: SortOrder.DESC,
470 },
471 );
472
473 // Should not return the note card since we only return URL cards
474 expect(result.items).toHaveLength(0);
475 expect(result.totalCount).toBe(0);
476 });
477
478 it('should handle sorting by library count in collection', async () => {
479 // Create URL cards - each URL card can only be in its creator's library
480 const url1 = URL.create('https://example.com/popular').unwrap();
481 const urlCard1 = new CardBuilder()
482 .withCuratorId(curatorId.value)
483 .withUrlCard(url1)
484 .buildOrThrow();
485
486 const url2 = URL.create('https://example.com/less-popular').unwrap();
487 const urlCard2 = new CardBuilder()
488 .withCuratorId(curatorId.value)
489 .withUrlCard(url2)
490 .buildOrThrow();
491
492 await cardRepository.save(urlCard1);
493 await cardRepository.save(urlCard2);
494
495 // Add cards to creator's library - URL cards can only be in creator's library
496 urlCard1.addToLibrary(curatorId);
497 await cardRepository.save(urlCard1);
498
499 urlCard2.addToLibrary(curatorId);
500 await cardRepository.save(urlCard2);
501
502 // Create collection and add both cards
503 const collection = Collection.create(
504 {
505 authorId: curatorId,
506 name: 'Popularity Collection',
507 accessType: CollectionAccessType.OPEN,
508 collaboratorIds: [],
509 createdAt: new Date(),
510 updatedAt: new Date(),
511 },
512 new UniqueEntityID(),
513 ).unwrap();
514
515 collection.addCard(urlCard1.cardId, curatorId);
516 collection.addCard(urlCard2.cardId, curatorId);
517 await collectionRepository.save(collection);
518
519 // Query cards sorted by library count descending
520 const result = await queryRepository.getCardsInCollection(
521 collection.collectionId.getStringValue(),
522 {
523 page: 1,
524 limit: 10,
525 sortBy: CardSortField.LIBRARY_COUNT,
526 sortOrder: SortOrder.DESC,
527 },
528 );
529
530 expect(result.items).toHaveLength(2);
531 // Both URL cards have library count of 1 (only in creator's library)
532 expect(result.items[0]?.libraryCount).toBe(1);
533 expect(result.items[1]?.libraryCount).toBe(1);
534 });
535
536 it('should handle pagination for collection cards', async () => {
537 // Create multiple URL cards
538 const urlCards = [];
539 for (let i = 1; i <= 5; i++) {
540 const url = URL.create(
541 `https://example.com/collection-article${i}`,
542 ).unwrap();
543 const urlCard = new CardBuilder()
544 .withCuratorId(curatorId.value)
545 .withUrlCard(url)
546 .withCreatedAt(new Date(`2023-01-${i.toString().padStart(2, '0')}`))
547 .withUpdatedAt(new Date(`2023-01-${i.toString().padStart(2, '0')}`))
548 .buildOrThrow();
549
550 await cardRepository.save(urlCard);
551 urlCards.push(urlCard);
552 }
553
554 // Create collection and add all cards
555 const collection = Collection.create(
556 {
557 authorId: curatorId,
558 name: 'Large Collection',
559 accessType: CollectionAccessType.OPEN,
560 collaboratorIds: [],
561 createdAt: new Date(),
562 updatedAt: new Date(),
563 },
564 new UniqueEntityID(),
565 ).unwrap();
566
567 for (const urlCard of urlCards) {
568 collection.addCard(urlCard.cardId, curatorId);
569 }
570 await collectionRepository.save(collection);
571
572 // Test first page
573 const page1 = await queryRepository.getCardsInCollection(
574 collection.collectionId.getStringValue(),
575 {
576 page: 1,
577 limit: 2,
578 sortBy: CardSortField.UPDATED_AT,
579 sortOrder: SortOrder.ASC,
580 },
581 );
582
583 expect(page1.items).toHaveLength(2);
584 expect(page1.totalCount).toBe(5);
585 expect(page1.hasMore).toBe(true);
586
587 // Test second page
588 const page2 = await queryRepository.getCardsInCollection(
589 collection.collectionId.getStringValue(),
590 {
591 page: 2,
592 limit: 2,
593 sortBy: CardSortField.UPDATED_AT,
594 sortOrder: SortOrder.ASC,
595 },
596 );
597
598 expect(page2.items).toHaveLength(2);
599 expect(page2.totalCount).toBe(5);
600 expect(page2.hasMore).toBe(true);
601
602 // Test last page
603 const page3 = await queryRepository.getCardsInCollection(
604 collection.collectionId.getStringValue(),
605 {
606 page: 3,
607 limit: 2,
608 sortBy: CardSortField.UPDATED_AT,
609 sortOrder: SortOrder.ASC,
610 },
611 );
612
613 expect(page3.items).toHaveLength(1);
614 expect(page3.totalCount).toBe(5);
615 expect(page3.hasMore).toBe(false);
616 });
617 });
618});