This repository has no description
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';
26import { UrlType } from '../../domain/value-objects/UrlType';
27
28describe('DrizzleCardQueryRepository - getUrlCardsOfUser', () => {
29 let container: StartedPostgreSqlContainer;
30 let db: PostgresJsDatabase;
31 let queryRepository: DrizzleCardQueryRepository;
32 let cardRepository: DrizzleCardRepository;
33 let collectionRepository: DrizzleCollectionRepository;
34
35 // Test data
36 let curatorId: CuratorId;
37 let otherCuratorId: CuratorId;
38 let thirdCuratorId: CuratorId;
39
40 // Setup before all tests
41 beforeAll(async () => {
42 // Start PostgreSQL container
43 container = await new PostgreSqlContainer('postgres:14').start();
44
45 // Create database connection
46 const connectionString = container.getConnectionUri();
47 process.env.DATABASE_URL = connectionString;
48 const client = postgres(connectionString);
49 db = drizzle(client);
50
51 // Create repositories
52 queryRepository = new DrizzleCardQueryRepository(db);
53 cardRepository = new DrizzleCardRepository(db);
54 collectionRepository = new DrizzleCollectionRepository(db);
55
56 // Create schema using helper function
57 await createTestSchema(db);
58
59 // Create test data
60 curatorId = CuratorId.create('did:plc:testcurator').unwrap();
61 otherCuratorId = CuratorId.create('did:plc:othercurator').unwrap();
62 thirdCuratorId = CuratorId.create('did:plc:thirdcurator').unwrap();
63 }, 60000); // Increase timeout for container startup
64
65 // Cleanup after all tests
66 afterAll(async () => {
67 // Stop container
68 await container.stop();
69 });
70
71 // Clear data between tests
72 beforeEach(async () => {
73 await db.delete(collectionCards);
74 await db.delete(collections);
75 await db.delete(libraryMemberships);
76 await db.delete(cards);
77 await db.delete(publishedRecords);
78 });
79
80 describe('getUrlCardsOfUser', () => {
81 it('should return empty result when user has no URL cards', async () => {
82 const result = await queryRepository.getUrlCardsOfUser(curatorId.value, {
83 page: 1,
84 limit: 10,
85 sortBy: CardSortField.UPDATED_AT,
86 sortOrder: SortOrder.DESC,
87 });
88
89 expect(result.items).toHaveLength(0);
90 expect(result.totalCount).toBe(0);
91 expect(result.hasMore).toBe(false);
92 });
93
94 it('should return URL cards for a user', async () => {
95 // Create URL cards
96 const url1 = URL.create('https://example.com/article1').unwrap();
97 const urlMetadata1 = UrlMetadata.create({
98 url: url1.value,
99 title: 'Example Article 1',
100 description: 'A great article about testing',
101 author: 'John Doe',
102 imageUrl: 'https://example.com/image1.jpg',
103 }).unwrap();
104
105 const urlCard1 = new CardBuilder()
106 .withCuratorId(curatorId.value)
107 .withUrlCard(url1, urlMetadata1)
108 .withCreatedAt(new Date('2023-01-01'))
109 .withUpdatedAt(new Date('2023-01-01'))
110 .buildOrThrow();
111
112 const url2 = URL.create('https://example.com/article2').unwrap();
113 const urlCard2 = new CardBuilder()
114 .withCuratorId(curatorId.value)
115 .withUrlCard(url2)
116 .withCreatedAt(new Date('2023-01-02'))
117 .withUpdatedAt(new Date('2023-01-02'))
118 .buildOrThrow();
119
120 // Save cards
121 await cardRepository.save(urlCard1);
122 await cardRepository.save(urlCard2);
123
124 // Add cards to user's library using domain logic
125 urlCard1.addToLibrary(curatorId);
126 urlCard2.addToLibrary(curatorId);
127
128 // Save the updated cards
129 await cardRepository.save(urlCard1);
130 await cardRepository.save(urlCard2);
131
132 // Query URL cards
133 const result = await queryRepository.getUrlCardsOfUser(curatorId.value, {
134 page: 1,
135 limit: 10,
136 sortBy: CardSortField.UPDATED_AT,
137 sortOrder: SortOrder.DESC,
138 });
139
140 expect(result.items).toHaveLength(2);
141 expect(result.totalCount).toBe(2);
142 expect(result.hasMore).toBe(false);
143
144 // Check URL card data
145 const card1Result = result.items.find((item) => item.url === url1.value);
146 const card2Result = result.items.find((item) => item.url === url2.value);
147
148 expect(card1Result).toBeDefined();
149 expect(card1Result?.type).toBe(CardTypeEnum.URL);
150 expect(card1Result?.cardContent.title).toBe('Example Article 1');
151 expect(card1Result?.cardContent.description).toBe(
152 'A great article about testing',
153 );
154 expect(card1Result?.cardContent.author).toBe('John Doe');
155 expect(card1Result?.cardContent.imageUrl).toBe(
156 'https://example.com/image1.jpg',
157 );
158
159 expect(card2Result).toBeDefined();
160 expect(card2Result?.type).toBe(CardTypeEnum.URL);
161 expect(card2Result?.cardContent.title).toBeUndefined(); // No metadata provided
162 });
163
164 it('should include connected note cards', async () => {
165 // Create URL card
166 const url = URL.create('https://example.com/article').unwrap();
167 const urlCard = new CardBuilder()
168 .withCuratorId(curatorId.value)
169 .withUrlCard(url)
170 .buildOrThrow();
171
172 await cardRepository.save(urlCard);
173
174 // Create note card connected to URL card
175 const noteCard = new CardBuilder()
176 .withCuratorId(curatorId.value)
177 .withNoteCard('This is my note about the article')
178 .withParentCard(urlCard.cardId)
179 .buildOrThrow();
180
181 await cardRepository.save(noteCard);
182
183 // Add both cards to user's library using domain logic
184 urlCard.addToLibrary(curatorId);
185 noteCard.addToLibrary(curatorId);
186
187 // Save the updated cards
188 await cardRepository.save(urlCard);
189 await cardRepository.save(noteCard);
190
191 // Query URL cards
192 const result = await queryRepository.getUrlCardsOfUser(curatorId.value, {
193 page: 1,
194 limit: 10,
195 sortBy: CardSortField.UPDATED_AT,
196 sortOrder: SortOrder.DESC,
197 });
198
199 expect(result.items).toHaveLength(1);
200 const urlCardResult = result.items[0];
201
202 expect(urlCardResult?.note).toBeDefined();
203 expect(urlCardResult?.note?.id).toBe(noteCard.cardId.getStringValue());
204 expect(urlCardResult?.note?.text).toBe(
205 'This is my note about the article',
206 );
207 });
208
209 it('should include collections that contain the URL cards', async () => {
210 // Create URL card
211 const url = URL.create('https://example.com/article').unwrap();
212 const urlCard = new CardBuilder()
213 .withCuratorId(curatorId.value)
214 .withUrlCard(url)
215 .buildOrThrow();
216
217 await cardRepository.save(urlCard);
218
219 // Create collections
220 const collection1 = Collection.create(
221 {
222 authorId: curatorId,
223 name: 'Reading List',
224 description: 'Articles to read',
225 accessType: CollectionAccessType.OPEN,
226 collaboratorIds: [],
227 createdAt: new Date(),
228 updatedAt: new Date(),
229 },
230 new UniqueEntityID(),
231 ).unwrap();
232
233 const collection2 = Collection.create(
234 {
235 authorId: curatorId,
236 name: 'Favorites',
237 accessType: CollectionAccessType.OPEN,
238 collaboratorIds: [],
239 createdAt: new Date(),
240 updatedAt: new Date(),
241 },
242 new UniqueEntityID(),
243 ).unwrap();
244
245 // Add card to collections
246 collection1.addCard(urlCard.cardId, curatorId);
247 collection2.addCard(urlCard.cardId, curatorId);
248
249 await collectionRepository.save(collection1);
250 await collectionRepository.save(collection2);
251
252 // Add card to user's library using domain logic
253 urlCard.addToLibrary(curatorId);
254
255 // Save the updated card
256 await cardRepository.save(urlCard);
257
258 // Query URL cards
259 const result = await queryRepository.getUrlCardsOfUser(curatorId.value, {
260 page: 1,
261 limit: 10,
262 sortBy: CardSortField.UPDATED_AT,
263 sortOrder: SortOrder.DESC,
264 });
265
266 expect(result.items).toHaveLength(1);
267 const urlCardResult = result.items[0];
268
269 expect(urlCardResult?.collections).toHaveLength(2);
270
271 const collectionNames = urlCardResult?.collections
272 .map((c) => c.name)
273 .sort();
274 expect(collectionNames).toEqual(['Favorites', 'Reading List']);
275
276 // Check collection details
277 const readingListCollection = urlCardResult?.collections.find(
278 (c) => c.name === 'Reading List',
279 );
280 expect(readingListCollection?.authorId).toBe(curatorId.value);
281 expect(readingListCollection?.id).toBe(
282 collection1.collectionId.getStringValue(),
283 );
284 });
285
286 it('should only return URL cards in the specified user library', async () => {
287 // Create URL card for first user
288 const url1 = URL.create('https://example.com/user1-article').unwrap();
289 const urlCard1 = new CardBuilder()
290 .withCuratorId(curatorId.value)
291 .withUrlCard(url1)
292 .buildOrThrow();
293
294 await cardRepository.save(urlCard1);
295
296 // Create URL card for second user
297 const url2 = URL.create('https://example.com/user2-article').unwrap();
298 const urlCard2 = new CardBuilder()
299 .withCuratorId(otherCuratorId.value)
300 .withUrlCard(url2)
301 .buildOrThrow();
302
303 await cardRepository.save(urlCard2);
304
305 // Add cards to their respective creator's libraries
306 urlCard1.addToLibrary(curatorId);
307 await cardRepository.save(urlCard1);
308
309 urlCard2.addToLibrary(otherCuratorId);
310 await cardRepository.save(urlCard2);
311
312 // Query URL cards for first user
313 const result1 = await queryRepository.getUrlCardsOfUser(curatorId.value, {
314 page: 1,
315 limit: 10,
316 sortBy: CardSortField.UPDATED_AT,
317 sortOrder: SortOrder.DESC,
318 });
319
320 // Query URL cards for second user
321 const result2 = await queryRepository.getUrlCardsOfUser(
322 otherCuratorId.value,
323 {
324 page: 1,
325 limit: 10,
326 sortBy: CardSortField.UPDATED_AT,
327 sortOrder: SortOrder.DESC,
328 },
329 );
330
331 // Each user should only see their own URL card
332 expect(result1.items).toHaveLength(1);
333 expect(result1.items[0]?.url).toBe(url1.value);
334
335 expect(result2.items).toHaveLength(1);
336 expect(result2.items[0]?.url).toBe(url2.value);
337 });
338
339 it("should not return URL cards from other users' libraries", async () => {
340 // Create URL card for other user
341 const url = URL.create('https://example.com/private-article').unwrap();
342 const urlCard = new CardBuilder()
343 .withCuratorId(otherCuratorId.value)
344 .withUrlCard(url)
345 .buildOrThrow();
346
347 await cardRepository.save(urlCard);
348
349 // Add card only to other user's library using domain logic
350 urlCard.addToLibrary(otherCuratorId);
351
352 // Save the updated card
353 await cardRepository.save(urlCard);
354
355 // Query URL cards for our user
356 const result = await queryRepository.getUrlCardsOfUser(curatorId.value, {
357 page: 1,
358 limit: 10,
359 sortBy: CardSortField.UPDATED_AT,
360 sortOrder: SortOrder.DESC,
361 });
362
363 expect(result.items).toHaveLength(0);
364 expect(result.totalCount).toBe(0);
365 });
366
367 it('should not return note cards that are not connected to URL cards', async () => {
368 // Create standalone note card
369 const noteCard = new CardBuilder()
370 .withCuratorId(curatorId.value)
371 .withNoteCard('Standalone note')
372 .buildOrThrow();
373
374 await cardRepository.save(noteCard);
375
376 // Add note card to user's library using domain logic
377 noteCard.addToLibrary(curatorId);
378
379 // Save the updated card
380 await cardRepository.save(noteCard);
381
382 // Query URL cards
383 const result = await queryRepository.getUrlCardsOfUser(curatorId.value, {
384 page: 1,
385 limit: 10,
386 sortBy: CardSortField.UPDATED_AT,
387 sortOrder: SortOrder.DESC,
388 });
389
390 // Should not return the note card since it's not a URL card
391 expect(result.items).toHaveLength(0);
392 expect(result.totalCount).toBe(0);
393 });
394
395 it('should handle complex scenario with URL card, note, and multiple collections', async () => {
396 // Create URL card with metadata
397 const url = URL.create('https://example.com/complex-article').unwrap();
398 const urlMetadata = UrlMetadata.create({
399 url: url.value,
400 title: 'Complex Article',
401 description: 'An article with notes and collections',
402 author: 'Jane Smith',
403 imageUrl: 'https://example.com/complex.jpg',
404 siteName: 'Example Site',
405 }).unwrap();
406
407 const urlCard = new CardBuilder()
408 .withCuratorId(curatorId.value)
409 .withUrlCard(url, urlMetadata)
410 .buildOrThrow();
411
412 await cardRepository.save(urlCard);
413
414 // Create connected note
415 const noteCard = new CardBuilder()
416 .withCuratorId(curatorId.value)
417 .withNoteCard('Detailed analysis of the complex article')
418 .withParentCard(urlCard.cardId)
419 .buildOrThrow();
420
421 await cardRepository.save(noteCard);
422
423 // Create multiple collections
424 const workCollection = Collection.create(
425 {
426 authorId: curatorId,
427 name: 'Work Research',
428 description: 'Articles for work projects',
429 accessType: CollectionAccessType.CLOSED,
430 collaboratorIds: [],
431 createdAt: new Date(),
432 updatedAt: new Date(),
433 },
434 new UniqueEntityID(),
435 ).unwrap();
436
437 const personalCollection = Collection.create(
438 {
439 authorId: curatorId,
440 name: 'Personal Reading',
441 accessType: CollectionAccessType.OPEN,
442 collaboratorIds: [],
443 createdAt: new Date(),
444 updatedAt: new Date(),
445 },
446 new UniqueEntityID(),
447 ).unwrap();
448
449 // Add URL card to collections
450 workCollection.addCard(urlCard.cardId, curatorId);
451 personalCollection.addCard(urlCard.cardId, curatorId);
452
453 await collectionRepository.save(workCollection);
454 await collectionRepository.save(personalCollection);
455
456 // Add both cards to user's library - URL cards can only be in creator's library
457 urlCard.addToLibrary(curatorId);
458 await cardRepository.save(urlCard);
459
460 noteCard.addToLibrary(curatorId);
461 await cardRepository.save(noteCard);
462
463 // Query URL cards
464 const result = await queryRepository.getUrlCardsOfUser(curatorId.value, {
465 page: 1,
466 limit: 10,
467 sortBy: CardSortField.UPDATED_AT,
468 sortOrder: SortOrder.DESC,
469 });
470
471 expect(result.items).toHaveLength(1);
472 const urlCardResult = result.items[0];
473
474 // Check URL metadata
475 expect(urlCardResult?.url).toBe(url.value);
476 expect(urlCardResult?.type).toBe(CardTypeEnum.URL);
477 expect(urlCardResult?.cardContent.title).toBe('Complex Article');
478 expect(urlCardResult?.cardContent.description).toBe(
479 'An article with notes and collections',
480 );
481 expect(urlCardResult?.cardContent.author).toBe('Jane Smith');
482 expect(urlCardResult?.cardContent.imageUrl).toBe(
483 'https://example.com/complex.jpg',
484 );
485
486 // Check library count - URL cards can only be in one library (creator's)
487 expect(urlCardResult?.libraryCount).toBe(1);
488
489 // Check connected note
490 expect(urlCardResult?.note).toBeDefined();
491 expect(urlCardResult?.note?.id).toBe(noteCard.cardId.getStringValue());
492 expect(urlCardResult?.note?.text).toBe(
493 'Detailed analysis of the complex article',
494 );
495
496 // Check collections
497 expect(urlCardResult?.collections).toHaveLength(2);
498 const collectionNames = urlCardResult?.collections
499 .map((c) => c.name)
500 .sort();
501 expect(collectionNames).toEqual(['Personal Reading', 'Work Research']);
502
503 // Verify collection details
504 const workColl = urlCardResult?.collections.find(
505 (c) => c.name === 'Work Research',
506 );
507 expect(workColl?.authorId).toBe(curatorId.value);
508 expect(workColl?.id).toBe(workCollection.collectionId.getStringValue());
509 });
510 });
511
512 describe('sorting', () => {
513 beforeEach(async () => {
514 // Create URL cards with different properties for sorting
515 // Each URL card is created by and belongs to curatorId
516 const urls = [
517 {
518 url: 'https://example.com/alpha',
519 date: '2023-01-01',
520 },
521 {
522 url: 'https://example.com/beta',
523 date: '2023-01-03',
524 },
525 {
526 url: 'https://example.com/gamma',
527 date: '2023-01-02',
528 },
529 ];
530
531 for (const urlData of urls) {
532 const url = URL.create(urlData.url).unwrap();
533 const urlCard = new CardBuilder()
534 .withCuratorId(curatorId.value)
535 .withUrlCard(url)
536 .withCreatedAt(new Date(urlData.date))
537 .withUpdatedAt(new Date(urlData.date))
538 .buildOrThrow();
539
540 await cardRepository.save(urlCard);
541
542 // Add to library using domain logic - URL cards can only be in creator's library
543 urlCard.addToLibrary(curatorId);
544 await cardRepository.save(urlCard);
545 }
546 });
547
548 it('should sort by library count descending', async () => {
549 const result = await queryRepository.getUrlCardsOfUser(curatorId.value, {
550 page: 1,
551 limit: 10,
552 sortBy: CardSortField.LIBRARY_COUNT,
553 sortOrder: SortOrder.DESC,
554 });
555
556 expect(result.items).toHaveLength(3);
557 // All URL cards have library count of 1 (only in creator's library)
558 expect(result.items[0]?.libraryCount).toBe(1);
559 expect(result.items[1]?.libraryCount).toBe(1);
560 expect(result.items[2]?.libraryCount).toBe(1);
561 });
562
563 it('should sort by library count ascending', async () => {
564 const result = await queryRepository.getUrlCardsOfUser(curatorId.value, {
565 page: 1,
566 limit: 10,
567 sortBy: CardSortField.LIBRARY_COUNT,
568 sortOrder: SortOrder.ASC,
569 });
570
571 expect(result.items).toHaveLength(3);
572 // All URL cards have library count of 1 (only in creator's library)
573 expect(result.items[0]?.libraryCount).toBe(1);
574 expect(result.items[1]?.libraryCount).toBe(1);
575 expect(result.items[2]?.libraryCount).toBe(1);
576 });
577
578 it('should sort by updated date descending', async () => {
579 const result = await queryRepository.getUrlCardsOfUser(curatorId.value, {
580 page: 1,
581 limit: 10,
582 sortBy: CardSortField.UPDATED_AT,
583 sortOrder: SortOrder.DESC,
584 });
585
586 expect(result.items).toHaveLength(3);
587 expect(result.items[0]!.updatedAt.getTime()).toBeGreaterThanOrEqual(
588 result.items[1]!.updatedAt.getTime(),
589 );
590 expect(result.items[1]!.updatedAt.getTime()).toBeGreaterThanOrEqual(
591 result.items[2]!.updatedAt.getTime(),
592 );
593 });
594 });
595
596 describe('urlLibraryCount', () => {
597 it('should return urlLibraryCount of 1 when only one user has the URL', async () => {
598 const url = URL.create('https://example.com/unique-article').unwrap();
599 const urlCard = new CardBuilder()
600 .withCuratorId(curatorId.value)
601 .withUrlCard(url)
602 .buildOrThrow();
603
604 await cardRepository.save(urlCard);
605 urlCard.addToLibrary(curatorId);
606 await cardRepository.save(urlCard);
607
608 const result = await queryRepository.getUrlCardsOfUser(curatorId.value, {
609 page: 1,
610 limit: 10,
611 sortBy: CardSortField.UPDATED_AT,
612 sortOrder: SortOrder.DESC,
613 });
614
615 expect(result.items).toHaveLength(1);
616 expect(result.items[0]?.libraryCount).toBe(1);
617 expect(result.items[0]?.urlLibraryCount).toBe(1);
618 });
619
620 it('should return urlLibraryCount of 3 when three users have cards with the same URL', async () => {
621 const sharedUrl = 'https://example.com/popular-article';
622 const url = URL.create(sharedUrl).unwrap();
623
624 // Create URL card for first user
625 const urlCard1 = new CardBuilder()
626 .withCuratorId(curatorId.value)
627 .withUrlCard(url)
628 .buildOrThrow();
629
630 await cardRepository.save(urlCard1);
631 urlCard1.addToLibrary(curatorId);
632 await cardRepository.save(urlCard1);
633
634 // Create URL card for second user (same URL)
635 const urlCard2 = new CardBuilder()
636 .withCuratorId(otherCuratorId.value)
637 .withUrlCard(url)
638 .buildOrThrow();
639
640 await cardRepository.save(urlCard2);
641 urlCard2.addToLibrary(otherCuratorId);
642 await cardRepository.save(urlCard2);
643
644 // Create URL card for third user (same URL)
645 const urlCard3 = new CardBuilder()
646 .withCuratorId(thirdCuratorId.value)
647 .withUrlCard(url)
648 .buildOrThrow();
649
650 await cardRepository.save(urlCard3);
651 urlCard3.addToLibrary(thirdCuratorId);
652 await cardRepository.save(urlCard3);
653
654 // Query from first user's perspective
655 const result = await queryRepository.getUrlCardsOfUser(curatorId.value, {
656 page: 1,
657 limit: 10,
658 sortBy: CardSortField.UPDATED_AT,
659 sortOrder: SortOrder.DESC,
660 });
661
662 expect(result.items).toHaveLength(1);
663 expect(result.items[0]?.url).toBe(sharedUrl);
664 expect(result.items[0]?.libraryCount).toBe(1); // Only in curator's library
665 expect(result.items[0]?.urlLibraryCount).toBe(3); // 3 users have this URL
666 });
667
668 it('should return different urlLibraryCounts for different URLs', async () => {
669 // URL 1: only curator has it
670 const url1 = URL.create('https://example.com/article1').unwrap();
671 const urlCard1 = new CardBuilder()
672 .withCuratorId(curatorId.value)
673 .withUrlCard(url1)
674 .withCreatedAt(new Date('2023-01-01'))
675 .withUpdatedAt(new Date('2023-01-01'))
676 .buildOrThrow();
677
678 await cardRepository.save(urlCard1);
679 urlCard1.addToLibrary(curatorId);
680 await cardRepository.save(urlCard1);
681
682 // URL 2: curator and otherCurator have it
683 const url2 = URL.create('https://example.com/article2').unwrap();
684 const urlCard2a = new CardBuilder()
685 .withCuratorId(curatorId.value)
686 .withUrlCard(url2)
687 .withCreatedAt(new Date('2023-01-02'))
688 .withUpdatedAt(new Date('2023-01-02'))
689 .buildOrThrow();
690
691 await cardRepository.save(urlCard2a);
692 urlCard2a.addToLibrary(curatorId);
693 await cardRepository.save(urlCard2a);
694
695 const urlCard2b = new CardBuilder()
696 .withCuratorId(otherCuratorId.value)
697 .withUrlCard(url2)
698 .buildOrThrow();
699
700 await cardRepository.save(urlCard2b);
701 urlCard2b.addToLibrary(otherCuratorId);
702 await cardRepository.save(urlCard2b);
703
704 // Query from curator's perspective
705 const result = await queryRepository.getUrlCardsOfUser(curatorId.value, {
706 page: 1,
707 limit: 10,
708 sortBy: CardSortField.UPDATED_AT,
709 sortOrder: SortOrder.DESC,
710 });
711
712 expect(result.items).toHaveLength(2);
713
714 const card1 = result.items.find((item) => item.url === url1.value);
715 const card2 = result.items.find((item) => item.url === url2.value);
716
717 expect(card1?.libraryCount).toBe(1);
718 expect(card1?.urlLibraryCount).toBe(1);
719
720 expect(card2?.libraryCount).toBe(1);
721 expect(card2?.urlLibraryCount).toBe(2);
722 });
723
724 it('should handle urlLibraryCount with multiple cards per user for same URL', async () => {
725 const sharedUrl = 'https://example.com/shared-article';
726 const url = URL.create(sharedUrl).unwrap();
727
728 // First user creates a card with this URL
729 const urlCard1 = new CardBuilder()
730 .withCuratorId(curatorId.value)
731 .withUrlCard(url)
732 .buildOrThrow();
733
734 await cardRepository.save(urlCard1);
735 urlCard1.addToLibrary(curatorId);
736 await cardRepository.save(urlCard1);
737
738 // Second user creates TWO cards with the same URL (edge case)
739 const urlCard2a = new CardBuilder()
740 .withCuratorId(otherCuratorId.value)
741 .withUrlCard(url)
742 .buildOrThrow();
743
744 await cardRepository.save(urlCard2a);
745 urlCard2a.addToLibrary(otherCuratorId);
746 await cardRepository.save(urlCard2a);
747
748 const urlCard2b = new CardBuilder()
749 .withCuratorId(otherCuratorId.value)
750 .withUrlCard(url)
751 .buildOrThrow();
752
753 await cardRepository.save(urlCard2b);
754 urlCard2b.addToLibrary(otherCuratorId);
755 await cardRepository.save(urlCard2b);
756
757 // Query from first user's perspective
758 const result = await queryRepository.getUrlCardsOfUser(curatorId.value, {
759 page: 1,
760 limit: 10,
761 sortBy: CardSortField.UPDATED_AT,
762 sortOrder: SortOrder.DESC,
763 });
764
765 expect(result.items).toHaveLength(1);
766 expect(result.items[0]?.libraryCount).toBe(1); // Only in first user's library
767 expect(result.items[0]?.urlLibraryCount).toBe(2); // 2 unique users have this URL
768 });
769 });
770
771 describe('pagination', () => {
772 beforeEach(async () => {
773 // Create 5 URL cards for pagination testing
774 for (let i = 1; i <= 5; i++) {
775 const url = URL.create(`https://example.com/article${i}`).unwrap();
776 const urlCard = new CardBuilder()
777 .withCuratorId(curatorId.value)
778 .withUrlCard(url)
779 .withCreatedAt(new Date(`2023-01-${i.toString().padStart(2, '0')}`))
780 .withUpdatedAt(new Date(`2023-01-${i.toString().padStart(2, '0')}`))
781 .buildOrThrow();
782
783 await cardRepository.save(urlCard);
784
785 // Add to library using domain logic
786 urlCard.addToLibrary(curatorId);
787
788 await cardRepository.save(urlCard);
789 }
790 });
791
792 it('should handle pagination correctly', async () => {
793 // First page
794 const page1 = await queryRepository.getUrlCardsOfUser(curatorId.value, {
795 page: 1,
796 limit: 2,
797 sortBy: CardSortField.UPDATED_AT,
798 sortOrder: SortOrder.ASC,
799 });
800
801 expect(page1.items).toHaveLength(2);
802 expect(page1.totalCount).toBe(5);
803 expect(page1.hasMore).toBe(true);
804
805 // Second page
806 const page2 = await queryRepository.getUrlCardsOfUser(curatorId.value, {
807 page: 2,
808 limit: 2,
809 sortBy: CardSortField.UPDATED_AT,
810 sortOrder: SortOrder.ASC,
811 });
812
813 expect(page2.items).toHaveLength(2);
814 expect(page2.totalCount).toBe(5);
815 expect(page2.hasMore).toBe(true);
816
817 // Third page (last page with 1 item)
818 const page3 = await queryRepository.getUrlCardsOfUser(curatorId.value, {
819 page: 3,
820 limit: 2,
821 sortBy: CardSortField.UPDATED_AT,
822 sortOrder: SortOrder.ASC,
823 });
824
825 expect(page3.items).toHaveLength(1);
826 expect(page3.totalCount).toBe(5);
827 expect(page3.hasMore).toBe(false);
828 });
829 });
830
831 describe('urlInLibrary', () => {
832 it('should return urlInLibrary as undefined when callingUserId is not provided', async () => {
833 const url = URL.create('https://example.com/test-article').unwrap();
834 const urlCard = new CardBuilder()
835 .withCuratorId(curatorId.value)
836 .withUrlCard(url)
837 .buildOrThrow();
838
839 await cardRepository.save(urlCard);
840 urlCard.addToLibrary(curatorId);
841 await cardRepository.save(urlCard);
842
843 // Query without callingUserId
844 const result = await queryRepository.getUrlCardsOfUser(curatorId.value, {
845 page: 1,
846 limit: 10,
847 sortBy: CardSortField.UPDATED_AT,
848 sortOrder: SortOrder.DESC,
849 });
850
851 expect(result.items).toHaveLength(1);
852 expect(result.items[0]?.urlInLibrary).toBeUndefined();
853 });
854
855 it('should return urlInLibrary as true when callingUserId has the URL in their library', async () => {
856 const sharedUrl = 'https://example.com/shared-article';
857 const url = URL.create(sharedUrl).unwrap();
858
859 // Create URL card for first user
860 const urlCard1 = new CardBuilder()
861 .withCuratorId(curatorId.value)
862 .withUrlCard(url)
863 .buildOrThrow();
864
865 await cardRepository.save(urlCard1);
866 urlCard1.addToLibrary(curatorId);
867 await cardRepository.save(urlCard1);
868
869 // Create URL card for second user with the same URL
870 const urlCard2 = new CardBuilder()
871 .withCuratorId(otherCuratorId.value)
872 .withUrlCard(url)
873 .buildOrThrow();
874
875 await cardRepository.save(urlCard2);
876 urlCard2.addToLibrary(otherCuratorId);
877 await cardRepository.save(urlCard2);
878
879 // Query first user's cards with second user as callingUserId
880 const result = await queryRepository.getUrlCardsOfUser(
881 curatorId.value,
882 {
883 page: 1,
884 limit: 10,
885 sortBy: CardSortField.UPDATED_AT,
886 sortOrder: SortOrder.DESC,
887 },
888 otherCuratorId.value, // callingUserId
889 );
890
891 expect(result.items).toHaveLength(1);
892 expect(result.items[0]?.url).toBe(sharedUrl);
893 expect(result.items[0]?.urlInLibrary).toBe(true); // otherCurator has this URL
894 });
895
896 it('should return urlInLibrary as false when callingUserId does not have the URL in their library', async () => {
897 const url = URL.create('https://example.com/unique-article').unwrap();
898
899 // Create URL card for first user
900 const urlCard = new CardBuilder()
901 .withCuratorId(curatorId.value)
902 .withUrlCard(url)
903 .buildOrThrow();
904
905 await cardRepository.save(urlCard);
906 urlCard.addToLibrary(curatorId);
907 await cardRepository.save(urlCard);
908
909 // Query first user's cards with second user as callingUserId (who doesn't have this URL)
910 const result = await queryRepository.getUrlCardsOfUser(
911 curatorId.value,
912 {
913 page: 1,
914 limit: 10,
915 sortBy: CardSortField.UPDATED_AT,
916 sortOrder: SortOrder.DESC,
917 },
918 otherCuratorId.value, // callingUserId who doesn't have this URL
919 );
920
921 expect(result.items).toHaveLength(1);
922 expect(result.items[0]?.urlInLibrary).toBe(false); // otherCurator doesn't have this URL
923 });
924
925 it('should return urlInLibrary as true when the card author is the same as callingUserId', async () => {
926 const url = URL.create('https://example.com/my-article').unwrap();
927
928 // Create URL card for curator
929 const urlCard = new CardBuilder()
930 .withCuratorId(curatorId.value)
931 .withUrlCard(url)
932 .buildOrThrow();
933
934 await cardRepository.save(urlCard);
935 urlCard.addToLibrary(curatorId);
936 await cardRepository.save(urlCard);
937
938 // Query with the same user as callingUserId
939 const result = await queryRepository.getUrlCardsOfUser(
940 curatorId.value,
941 {
942 page: 1,
943 limit: 10,
944 sortBy: CardSortField.UPDATED_AT,
945 sortOrder: SortOrder.DESC,
946 },
947 curatorId.value, // same as card author
948 );
949
950 expect(result.items).toHaveLength(1);
951 expect(result.items[0]?.urlInLibrary).toBe(true); // curator has their own URL
952 });
953
954 it('should return urlInLibrary correctly when user has multiple cards with the same URL', async () => {
955 const sharedUrl = 'https://example.com/multi-card-url';
956 const url = URL.create(sharedUrl).unwrap();
957
958 // Create first URL card for curator
959 const urlCard1 = new CardBuilder()
960 .withCuratorId(curatorId.value)
961 .withUrlCard(url)
962 .buildOrThrow();
963
964 await cardRepository.save(urlCard1);
965 urlCard1.addToLibrary(curatorId);
966 await cardRepository.save(urlCard1);
967
968 // Create URL card for otherCurator with the same URL (multiple cards)
969 const urlCard2a = new CardBuilder()
970 .withCuratorId(otherCuratorId.value)
971 .withUrlCard(url)
972 .buildOrThrow();
973
974 await cardRepository.save(urlCard2a);
975 urlCard2a.addToLibrary(otherCuratorId);
976 await cardRepository.save(urlCard2a);
977
978 // Create ANOTHER URL card for otherCurator with the same URL
979 const urlCard2b = new CardBuilder()
980 .withCuratorId(otherCuratorId.value)
981 .withUrlCard(url)
982 .buildOrThrow();
983
984 await cardRepository.save(urlCard2b);
985 urlCard2b.addToLibrary(otherCuratorId);
986 await cardRepository.save(urlCard2b);
987
988 // Query first user's cards with second user as callingUserId
989 const result = await queryRepository.getUrlCardsOfUser(
990 curatorId.value,
991 {
992 page: 1,
993 limit: 10,
994 sortBy: CardSortField.UPDATED_AT,
995 sortOrder: SortOrder.DESC,
996 },
997 otherCuratorId.value, // callingUserId who has multiple cards with this URL
998 );
999
1000 expect(result.items).toHaveLength(1);
1001 expect(result.items[0]?.url).toBe(sharedUrl);
1002 expect(result.items[0]?.urlInLibrary).toBe(true); // otherCurator has this URL (even with multiple cards)
1003 });
1004
1005 it('should handle multiple URLs with different urlInLibrary values', async () => {
1006 // URL 1: curator has it, otherCurator also has it
1007 const url1 = URL.create('https://example.com/shared-url').unwrap();
1008 const urlCard1a = new CardBuilder()
1009 .withCuratorId(curatorId.value)
1010 .withUrlCard(url1)
1011 .withCreatedAt(new Date('2023-01-01'))
1012 .withUpdatedAt(new Date('2023-01-01'))
1013 .buildOrThrow();
1014
1015 await cardRepository.save(urlCard1a);
1016 urlCard1a.addToLibrary(curatorId);
1017 await cardRepository.save(urlCard1a);
1018
1019 const urlCard1b = new CardBuilder()
1020 .withCuratorId(otherCuratorId.value)
1021 .withUrlCard(url1)
1022 .buildOrThrow();
1023
1024 await cardRepository.save(urlCard1b);
1025 urlCard1b.addToLibrary(otherCuratorId);
1026 await cardRepository.save(urlCard1b);
1027
1028 // URL 2: curator has it, otherCurator does NOT have it
1029 const url2 = URL.create('https://example.com/unique-url').unwrap();
1030 const urlCard2 = new CardBuilder()
1031 .withCuratorId(curatorId.value)
1032 .withUrlCard(url2)
1033 .withCreatedAt(new Date('2023-01-02'))
1034 .withUpdatedAt(new Date('2023-01-02'))
1035 .buildOrThrow();
1036
1037 await cardRepository.save(urlCard2);
1038 urlCard2.addToLibrary(curatorId);
1039 await cardRepository.save(urlCard2);
1040
1041 // Query curator's cards with otherCurator as callingUserId
1042 const result = await queryRepository.getUrlCardsOfUser(
1043 curatorId.value,
1044 {
1045 page: 1,
1046 limit: 10,
1047 sortBy: CardSortField.UPDATED_AT,
1048 sortOrder: SortOrder.DESC,
1049 },
1050 otherCuratorId.value,
1051 );
1052
1053 expect(result.items).toHaveLength(2);
1054
1055 const card1 = result.items.find((item) => item.url === url1.value);
1056 const card2 = result.items.find((item) => item.url === url2.value);
1057
1058 expect(card1?.urlInLibrary).toBe(true); // otherCurator has this URL
1059 expect(card2?.urlInLibrary).toBe(false); // otherCurator doesn't have this URL
1060 });
1061
1062 it('should correctly handle urlInLibrary with third user checking two other users URLs', async () => {
1063 const sharedUrl = 'https://example.com/popular-url';
1064 const url = URL.create(sharedUrl).unwrap();
1065
1066 // First user creates card with this URL
1067 const urlCard1 = new CardBuilder()
1068 .withCuratorId(curatorId.value)
1069 .withUrlCard(url)
1070 .buildOrThrow();
1071
1072 await cardRepository.save(urlCard1);
1073 urlCard1.addToLibrary(curatorId);
1074 await cardRepository.save(urlCard1);
1075
1076 // Second user creates card with the same URL
1077 const urlCard2 = new CardBuilder()
1078 .withCuratorId(otherCuratorId.value)
1079 .withUrlCard(url)
1080 .buildOrThrow();
1081
1082 await cardRepository.save(urlCard2);
1083 urlCard2.addToLibrary(otherCuratorId);
1084 await cardRepository.save(urlCard2);
1085
1086 // Query first user's cards with third user as callingUserId (who doesn't have this URL)
1087 const result = await queryRepository.getUrlCardsOfUser(
1088 curatorId.value,
1089 {
1090 page: 1,
1091 limit: 10,
1092 sortBy: CardSortField.UPDATED_AT,
1093 sortOrder: SortOrder.DESC,
1094 },
1095 thirdCuratorId.value, // third user checking
1096 );
1097
1098 expect(result.items).toHaveLength(1);
1099 expect(result.items[0]?.url).toBe(sharedUrl);
1100 expect(result.items[0]?.urlInLibrary).toBe(false); // thirdCurator doesn't have this URL
1101 expect(result.items[0]?.urlLibraryCount).toBe(2); // But 2 users have it
1102 });
1103 });
1104
1105 describe('URL type filtering', () => {
1106 it('should filter URL cards by urlType', async () => {
1107 // Create URL cards with different types
1108 const articleUrl = URL.create('https://example.com/article').unwrap();
1109 const articleMetadata = UrlMetadata.create({
1110 url: articleUrl.value,
1111 title: 'Test Article',
1112 type: UrlType.ARTICLE,
1113 }).unwrap();
1114
1115 const videoUrl = URL.create('https://example.com/video').unwrap();
1116 const videoMetadata = UrlMetadata.create({
1117 url: videoUrl.value,
1118 title: 'Test Video',
1119 type: UrlType.VIDEO,
1120 }).unwrap();
1121
1122 const articleCard = new CardBuilder()
1123 .withCuratorId(curatorId.value)
1124 .withUrlCard(articleUrl, articleMetadata)
1125 .buildOrThrow();
1126
1127 const videoCard = new CardBuilder()
1128 .withCuratorId(curatorId.value)
1129 .withUrlCard(videoUrl, videoMetadata)
1130 .buildOrThrow();
1131
1132 await cardRepository.save(articleCard);
1133 await cardRepository.save(videoCard);
1134
1135 articleCard.addToLibrary(curatorId);
1136 videoCard.addToLibrary(curatorId);
1137
1138 await cardRepository.save(articleCard);
1139 await cardRepository.save(videoCard);
1140
1141 // Query for only article type
1142 const articleResult = await queryRepository.getUrlCardsOfUser(
1143 curatorId.value,
1144 {
1145 page: 1,
1146 limit: 10,
1147 sortBy: CardSortField.UPDATED_AT,
1148 sortOrder: SortOrder.DESC,
1149 urlType: UrlType.ARTICLE,
1150 },
1151 );
1152
1153 expect(articleResult.items).toHaveLength(1);
1154 expect(articleResult.items[0]?.url).toBe(articleUrl.value);
1155
1156 // Query for only video type
1157 const videoResult = await queryRepository.getUrlCardsOfUser(
1158 curatorId.value,
1159 {
1160 page: 1,
1161 limit: 10,
1162 sortBy: CardSortField.UPDATED_AT,
1163 sortOrder: SortOrder.DESC,
1164 urlType: UrlType.VIDEO,
1165 },
1166 );
1167
1168 expect(videoResult.items).toHaveLength(1);
1169 expect(videoResult.items[0]?.url).toBe(videoUrl.value);
1170 });
1171
1172 it('should return empty result when no cards match the urlType filter', async () => {
1173 const articleUrl = URL.create('https://example.com/article').unwrap();
1174 const articleMetadata = UrlMetadata.create({
1175 url: articleUrl.value,
1176 title: 'Test Article',
1177 type: UrlType.ARTICLE,
1178 }).unwrap();
1179
1180 const articleCard = new CardBuilder()
1181 .withCuratorId(curatorId.value)
1182 .withUrlCard(articleUrl, articleMetadata)
1183 .buildOrThrow();
1184
1185 await cardRepository.save(articleCard);
1186 articleCard.addToLibrary(curatorId);
1187 await cardRepository.save(articleCard);
1188
1189 // Query for video type when only article exists
1190 const result = await queryRepository.getUrlCardsOfUser(curatorId.value, {
1191 page: 1,
1192 limit: 10,
1193 sortBy: CardSortField.UPDATED_AT,
1194 sortOrder: SortOrder.DESC,
1195 urlType: UrlType.VIDEO,
1196 });
1197
1198 expect(result.items).toHaveLength(0);
1199 expect(result.totalCount).toBe(0);
1200 });
1201 });
1202});