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 { DrizzleCollectionQueryRepository } from '../../infrastructure/repositories/DrizzleCollectionQueryRepository';
8import { DrizzleCardRepository } from '../../infrastructure/repositories/DrizzleCardRepository';
9import { DrizzleCollectionRepository } from '../../infrastructure/repositories/DrizzleCollectionRepository';
10import { CuratorId } from '../../domain/value-objects/CuratorId';
11import { cards } from '../../infrastructure/repositories/schema/card.sql';
12import {
13 collections,
14 collectionCards,
15} from '../../infrastructure/repositories/schema/collection.sql';
16import { libraryMemberships } from '../../infrastructure/repositories/schema/libraryMembership.sql';
17import { publishedRecords } from '../../infrastructure/repositories/schema/publishedRecord.sql';
18import { CardBuilder } from '../utils/builders/CardBuilder';
19import { CollectionBuilder } from '../utils/builders/CollectionBuilder';
20import { URL } from '../../domain/value-objects/URL';
21import { createTestSchema } from '../test-utils/createTestSchema';
22import { CardTypeEnum } from '../../domain/value-objects/CardType';
23import { PublishedRecordId } from '../../domain/value-objects/PublishedRecordId';
24import {
25 CollectionSortField,
26 SortOrder,
27} from '../../domain/ICollectionQueryRepository';
28
29describe('DrizzleCollectionQueryRepository - getCollectionsWithUrl', () => {
30 let container: StartedPostgreSqlContainer;
31 let db: PostgresJsDatabase;
32 let queryRepository: DrizzleCollectionQueryRepository;
33 let cardRepository: DrizzleCardRepository;
34 let collectionRepository: DrizzleCollectionRepository;
35
36 // Test data
37 let curator1: CuratorId;
38 let curator2: CuratorId;
39 let curator3: CuratorId;
40
41 // Setup before all tests
42 beforeAll(async () => {
43 // Start PostgreSQL container
44 container = await new PostgreSqlContainer('postgres:14').start();
45
46 // Create database connection
47 const connectionString = container.getConnectionUri();
48 process.env.DATABASE_URL = connectionString;
49 const client = postgres(connectionString);
50 db = drizzle(client);
51
52 // Create repositories
53 queryRepository = new DrizzleCollectionQueryRepository(db);
54 cardRepository = new DrizzleCardRepository(db);
55 collectionRepository = new DrizzleCollectionRepository(db);
56
57 // Create schema using helper function
58 await createTestSchema(db);
59
60 // Create test data
61 curator1 = CuratorId.create('did:plc:curator1').unwrap();
62 curator2 = CuratorId.create('did:plc:curator2').unwrap();
63 curator3 = CuratorId.create('did:plc:curator3').unwrap();
64 }, 60000); // Increase timeout for container startup
65
66 // Cleanup after all tests
67 afterAll(async () => {
68 // Stop container
69 await container.stop();
70 });
71
72 // Clear data between tests
73 beforeEach(async () => {
74 await db.delete(collectionCards);
75 await db.delete(collections);
76 await db.delete(libraryMemberships);
77 await db.delete(cards);
78 await db.delete(publishedRecords);
79 });
80
81 describe('Collections with URL cards', () => {
82 it('should return all collections containing cards with the specified URL', async () => {
83 const testUrl = 'https://example.com/shared-article';
84 const url = URL.create(testUrl).unwrap();
85
86 // Create URL cards for different users with the same URL
87 const card1 = new CardBuilder()
88 .withCuratorId(curator1.value)
89 .withType(CardTypeEnum.URL)
90 .withUrl(url)
91 .buildOrThrow();
92
93 const card2 = new CardBuilder()
94 .withCuratorId(curator2.value)
95 .withType(CardTypeEnum.URL)
96 .withUrl(url)
97 .buildOrThrow();
98
99 const card3 = new CardBuilder()
100 .withCuratorId(curator3.value)
101 .withType(CardTypeEnum.URL)
102 .withUrl(url)
103 .buildOrThrow();
104
105 // Add cards to their respective libraries
106 card1.addToLibrary(curator1);
107 card2.addToLibrary(curator2);
108 card3.addToLibrary(curator3);
109
110 await cardRepository.save(card1);
111 await cardRepository.save(card2);
112 await cardRepository.save(card3);
113
114 // Create collections for each user and add their cards
115 const collection1 = new CollectionBuilder()
116 .withAuthorId(curator1.value)
117 .withName('Tech Articles')
118 .withDescription('My tech articles')
119 .buildOrThrow();
120
121 const collection2 = new CollectionBuilder()
122 .withAuthorId(curator2.value)
123 .withName('Reading List')
124 .withDescription('Articles to read')
125 .buildOrThrow();
126
127 const collection3 = new CollectionBuilder()
128 .withAuthorId(curator3.value)
129 .withName('Favorites')
130 .buildOrThrow();
131
132 // Add cards to collections
133 collection1.addCard(card1.cardId, curator1);
134 collection2.addCard(card2.cardId, curator2);
135 collection3.addCard(card3.cardId, curator3);
136
137 // Mark collections as published
138 const publishedRecordId1 = PublishedRecordId.create({
139 uri: 'at://did:plc:curator1/network.cosmik.collection/collection1',
140 cid: 'bafyreicollection1',
141 });
142 const publishedRecordId2 = PublishedRecordId.create({
143 uri: 'at://did:plc:curator2/network.cosmik.collection/collection2',
144 cid: 'bafyreicollection2',
145 });
146 const publishedRecordId3 = PublishedRecordId.create({
147 uri: 'at://did:plc:curator3/network.cosmik.collection/collection3',
148 cid: 'bafyreicollection3',
149 });
150
151 collection1.markAsPublished(publishedRecordId1);
152 collection2.markAsPublished(publishedRecordId2);
153 collection3.markAsPublished(publishedRecordId3);
154
155 await collectionRepository.save(collection1);
156 await collectionRepository.save(collection2);
157 await collectionRepository.save(collection3);
158
159 // Execute the query
160 const result = await queryRepository.getCollectionsWithUrl(testUrl, {
161 page: 1,
162 limit: 10,
163 sortBy: CollectionSortField.NAME,
164 sortOrder: SortOrder.ASC,
165 });
166
167 // Verify the result
168 expect(result.items).toHaveLength(3);
169 expect(result.totalCount).toBe(3);
170 expect(result.hasMore).toBe(false);
171
172 // Check that all three collections are included
173 const collectionIds = result.items.map((c) => c.id);
174 expect(collectionIds).toContain(
175 collection1.collectionId.getStringValue(),
176 );
177 expect(collectionIds).toContain(
178 collection2.collectionId.getStringValue(),
179 );
180 expect(collectionIds).toContain(
181 collection3.collectionId.getStringValue(),
182 );
183
184 // Verify collection details
185 const techArticles = result.items.find((c) => c.name === 'Tech Articles');
186 expect(techArticles).toBeDefined();
187 expect(techArticles?.description).toBe('My tech articles');
188 expect(techArticles?.authorId).toBe(curator1.value);
189 expect(techArticles?.uri).toBe(
190 'at://did:plc:curator1/network.cosmik.collection/collection1',
191 );
192
193 const readingList = result.items.find((c) => c.name === 'Reading List');
194 expect(readingList).toBeDefined();
195 expect(readingList?.description).toBe('Articles to read');
196 expect(readingList?.authorId).toBe(curator2.value);
197
198 const favorites = result.items.find((c) => c.name === 'Favorites');
199 expect(favorites).toBeDefined();
200 expect(favorites?.description).toBeUndefined();
201 expect(favorites?.authorId).toBe(curator3.value);
202 });
203
204 it('should return empty array when no collections contain cards with the specified URL', async () => {
205 const testUrl = 'https://example.com/nonexistent-article';
206
207 const result = await queryRepository.getCollectionsWithUrl(testUrl, {
208 page: 1,
209 limit: 10,
210 sortBy: CollectionSortField.NAME,
211 sortOrder: SortOrder.ASC,
212 });
213
214 expect(result.items).toHaveLength(0);
215 expect(result.totalCount).toBe(0);
216 expect(result.hasMore).toBe(false);
217 });
218
219 it('should not return collections that contain cards with different URLs', async () => {
220 const testUrl1 = 'https://example.com/article1';
221 const testUrl2 = 'https://example.com/article2';
222 const url1 = URL.create(testUrl1).unwrap();
223 const url2 = URL.create(testUrl2).unwrap();
224
225 // Create cards with different URLs
226 const card1 = new CardBuilder()
227 .withCuratorId(curator1.value)
228 .withType(CardTypeEnum.URL)
229 .withUrl(url1)
230 .buildOrThrow();
231
232 const card2 = new CardBuilder()
233 .withCuratorId(curator2.value)
234 .withType(CardTypeEnum.URL)
235 .withUrl(url2)
236 .buildOrThrow();
237
238 card1.addToLibrary(curator1);
239 card2.addToLibrary(curator2);
240
241 await cardRepository.save(card1);
242 await cardRepository.save(card2);
243
244 // Create collections
245 const collection1 = new CollectionBuilder()
246 .withAuthorId(curator1.value)
247 .withName('Collection 1')
248 .buildOrThrow();
249
250 const collection2 = new CollectionBuilder()
251 .withAuthorId(curator2.value)
252 .withName('Collection 2')
253 .buildOrThrow();
254
255 collection1.addCard(card1.cardId, curator1);
256 collection2.addCard(card2.cardId, curator2);
257
258 await collectionRepository.save(collection1);
259 await collectionRepository.save(collection2);
260
261 // Query for testUrl1
262 const result = await queryRepository.getCollectionsWithUrl(testUrl1, {
263 page: 1,
264 limit: 10,
265 sortBy: CollectionSortField.NAME,
266 sortOrder: SortOrder.ASC,
267 });
268
269 expect(result.items).toHaveLength(1);
270 expect(result.items[0]!.name).toBe('Collection 1');
271 expect(result.items[0]!.authorId).toBe(curator1.value);
272 });
273
274 it('should return multiple collections from the same user if they contain the URL', async () => {
275 const testUrl = 'https://example.com/popular-article';
276 const url = URL.create(testUrl).unwrap();
277
278 // Create URL card
279 const card = new CardBuilder()
280 .withCuratorId(curator1.value)
281 .withType(CardTypeEnum.URL)
282 .withUrl(url)
283 .buildOrThrow();
284
285 card.addToLibrary(curator1);
286 await cardRepository.save(card);
287
288 // Create multiple collections for the same user
289 const collection1 = new CollectionBuilder()
290 .withAuthorId(curator1.value)
291 .withName('Tech')
292 .buildOrThrow();
293
294 const collection2 = new CollectionBuilder()
295 .withAuthorId(curator1.value)
296 .withName('Favorites')
297 .buildOrThrow();
298
299 const collection3 = new CollectionBuilder()
300 .withAuthorId(curator1.value)
301 .withName('To Read')
302 .buildOrThrow();
303
304 // Add the same card to all collections
305 collection1.addCard(card.cardId, curator1);
306 collection2.addCard(card.cardId, curator1);
307 collection3.addCard(card.cardId, curator1);
308
309 await collectionRepository.save(collection1);
310 await collectionRepository.save(collection2);
311 await collectionRepository.save(collection3);
312
313 // Execute the query
314 const result = await queryRepository.getCollectionsWithUrl(testUrl, {
315 page: 1,
316 limit: 10,
317 sortBy: CollectionSortField.NAME,
318 sortOrder: SortOrder.ASC,
319 });
320
321 expect(result.items).toHaveLength(3);
322
323 const collectionNames = result.items.map((c) => c.name);
324 expect(collectionNames).toContain('Tech');
325 expect(collectionNames).toContain('Favorites');
326 expect(collectionNames).toContain('To Read');
327
328 // All should have the same author
329 result.items.forEach((collection) => {
330 expect(collection.authorId).toBe(curator1.value);
331 });
332 });
333
334 it('should handle collections without published record IDs', async () => {
335 const testUrl = 'https://example.com/unpublished-article';
336 const url = URL.create(testUrl).unwrap();
337
338 // Create URL card
339 const card = new CardBuilder()
340 .withCuratorId(curator1.value)
341 .withType(CardTypeEnum.URL)
342 .withUrl(url)
343 .buildOrThrow();
344
345 card.addToLibrary(curator1);
346 await cardRepository.save(card);
347
348 // Create collection without publishing it
349 const collection = new CollectionBuilder()
350 .withAuthorId(curator1.value)
351 .withName('Unpublished Collection')
352 .buildOrThrow();
353
354 collection.addCard(card.cardId, curator1);
355 await collectionRepository.save(collection);
356
357 // Execute the query
358 const result = await queryRepository.getCollectionsWithUrl(testUrl, {
359 page: 1,
360 limit: 10,
361 sortBy: CollectionSortField.NAME,
362 sortOrder: SortOrder.ASC,
363 });
364
365 expect(result.items).toHaveLength(1);
366 expect(result.items[0]!.name).toBe('Unpublished Collection');
367 expect(result.items[0]!.uri).toBeUndefined();
368 });
369
370 it('should handle multiple cards with same URL from different users in same collection', async () => {
371 const testUrl = 'https://example.com/shared-article';
372 const url = URL.create(testUrl).unwrap();
373
374 // Create URL cards for different users with the same URL
375 const card1 = new CardBuilder()
376 .withCuratorId(curator1.value)
377 .withType(CardTypeEnum.URL)
378 .withUrl(url)
379 .buildOrThrow();
380
381 const card2 = new CardBuilder()
382 .withCuratorId(curator2.value)
383 .withType(CardTypeEnum.URL)
384 .withUrl(url)
385 .buildOrThrow();
386
387 card1.addToLibrary(curator1);
388 card2.addToLibrary(curator2);
389
390 await cardRepository.save(card1);
391 await cardRepository.save(card2);
392
393 // Create one collection that contains both cards
394 const collection = new CollectionBuilder()
395 .withAuthorId(curator1.value)
396 .withName('Shared Collection')
397 .buildOrThrow();
398
399 collection.addCard(card1.cardId, curator1);
400 collection.addCard(card2.cardId, curator1);
401
402 await collectionRepository.save(collection);
403
404 // Execute the query
405 const result = await queryRepository.getCollectionsWithUrl(testUrl, {
406 page: 1,
407 limit: 10,
408 sortBy: CollectionSortField.NAME,
409 sortOrder: SortOrder.ASC,
410 });
411
412 // Should return the collection only once, even though it has multiple cards with the URL
413 expect(result.items).toHaveLength(1);
414 expect(result.items[0]!.name).toBe('Shared Collection');
415 expect(result.items[0]!.authorId).toBe(curator1.value);
416 });
417
418 it('should not return collections containing NOTE cards with the URL', async () => {
419 const testUrl = 'https://example.com/article';
420 const url = URL.create(testUrl).unwrap();
421
422 // Create URL card
423 const urlCard = new CardBuilder()
424 .withCuratorId(curator1.value)
425 .withType(CardTypeEnum.URL)
426 .withUrl(url)
427 .buildOrThrow();
428
429 // Create NOTE card with same URL (edge case)
430 const noteCard = new CardBuilder()
431 .withCuratorId(curator2.value)
432 .withType(CardTypeEnum.NOTE)
433 .withUrl(url)
434 .buildOrThrow();
435
436 urlCard.addToLibrary(curator1);
437 noteCard.addToLibrary(curator2);
438
439 await cardRepository.save(urlCard);
440 await cardRepository.save(noteCard);
441
442 // Create collections
443 const collection1 = new CollectionBuilder()
444 .withAuthorId(curator1.value)
445 .withName('URL Collection')
446 .buildOrThrow();
447
448 const collection2 = new CollectionBuilder()
449 .withAuthorId(curator2.value)
450 .withName('Note Collection')
451 .buildOrThrow();
452
453 collection1.addCard(urlCard.cardId, curator1);
454 collection2.addCard(noteCard.cardId, curator2);
455
456 await collectionRepository.save(collection1);
457 await collectionRepository.save(collection2);
458
459 const result = await queryRepository.getCollectionsWithUrl(testUrl, {
460 page: 1,
461 limit: 10,
462 sortBy: CollectionSortField.NAME,
463 sortOrder: SortOrder.ASC,
464 });
465
466 // Should only return the collection with the URL card, not the NOTE card
467 expect(result.items).toHaveLength(1);
468 expect(result.items[0]!.name).toBe('URL Collection');
469 expect(result.items[0]!.authorId).toBe(curator1.value);
470 });
471
472 it('should handle cards not in any collection', async () => {
473 const testUrl = 'https://example.com/article';
474 const url = URL.create(testUrl).unwrap();
475
476 // Create URL card but don't add to any collection
477 const card = new CardBuilder()
478 .withCuratorId(curator1.value)
479 .withType(CardTypeEnum.URL)
480 .withUrl(url)
481 .buildOrThrow();
482
483 card.addToLibrary(curator1);
484 await cardRepository.save(card);
485
486 const result = await queryRepository.getCollectionsWithUrl(testUrl, {
487 page: 1,
488 limit: 10,
489 sortBy: CollectionSortField.NAME,
490 sortOrder: SortOrder.ASC,
491 });
492
493 // Should return empty since card is not in any collection
494 expect(result.items).toHaveLength(0);
495 });
496
497 it('should return collections sorted alphabetically by name in ascending order', async () => {
498 const testUrl = 'https://example.com/article';
499 const url = URL.create(testUrl).unwrap();
500
501 // Create URL cards
502 const card1 = new CardBuilder()
503 .withCuratorId(curator1.value)
504 .withType(CardTypeEnum.URL)
505 .withUrl(url)
506 .buildOrThrow();
507
508 const card2 = new CardBuilder()
509 .withCuratorId(curator2.value)
510 .withType(CardTypeEnum.URL)
511 .withUrl(url)
512 .buildOrThrow();
513
514 const card3 = new CardBuilder()
515 .withCuratorId(curator3.value)
516 .withType(CardTypeEnum.URL)
517 .withUrl(url)
518 .buildOrThrow();
519
520 card1.addToLibrary(curator1);
521 card2.addToLibrary(curator2);
522 card3.addToLibrary(curator3);
523
524 await cardRepository.save(card1);
525 await cardRepository.save(card2);
526 await cardRepository.save(card3);
527
528 // Create collections with names that should be sorted
529 const collectionZ = new CollectionBuilder()
530 .withAuthorId(curator1.value)
531 .withName('Zebra Collection')
532 .buildOrThrow();
533
534 const collectionA = new CollectionBuilder()
535 .withAuthorId(curator2.value)
536 .withName('Apple Collection')
537 .buildOrThrow();
538
539 const collectionM = new CollectionBuilder()
540 .withAuthorId(curator3.value)
541 .withName('Mango Collection')
542 .buildOrThrow();
543
544 collectionZ.addCard(card1.cardId, curator1);
545 collectionA.addCard(card2.cardId, curator2);
546 collectionM.addCard(card3.cardId, curator3);
547
548 await collectionRepository.save(collectionZ);
549 await collectionRepository.save(collectionA);
550 await collectionRepository.save(collectionM);
551
552 const result = await queryRepository.getCollectionsWithUrl(testUrl, {
553 page: 1,
554 limit: 10,
555 sortBy: CollectionSortField.NAME,
556 sortOrder: SortOrder.ASC,
557 });
558
559 expect(result.items).toHaveLength(3);
560 expect(result.items[0]!.name).toBe('Apple Collection');
561 expect(result.items[1]!.name).toBe('Mango Collection');
562 expect(result.items[2]!.name).toBe('Zebra Collection');
563 });
564
565 it('should return collections sorted alphabetically by name in descending order', async () => {
566 const testUrl = 'https://example.com/article';
567 const url = URL.create(testUrl).unwrap();
568
569 // Create URL cards
570 const card1 = new CardBuilder()
571 .withCuratorId(curator1.value)
572 .withType(CardTypeEnum.URL)
573 .withUrl(url)
574 .buildOrThrow();
575
576 const card2 = new CardBuilder()
577 .withCuratorId(curator2.value)
578 .withType(CardTypeEnum.URL)
579 .withUrl(url)
580 .buildOrThrow();
581
582 const card3 = new CardBuilder()
583 .withCuratorId(curator3.value)
584 .withType(CardTypeEnum.URL)
585 .withUrl(url)
586 .buildOrThrow();
587
588 card1.addToLibrary(curator1);
589 card2.addToLibrary(curator2);
590 card3.addToLibrary(curator3);
591
592 await cardRepository.save(card1);
593 await cardRepository.save(card2);
594 await cardRepository.save(card3);
595
596 // Create collections with names that should be sorted
597 const collectionZ = new CollectionBuilder()
598 .withAuthorId(curator1.value)
599 .withName('Zebra Collection')
600 .buildOrThrow();
601
602 const collectionA = new CollectionBuilder()
603 .withAuthorId(curator2.value)
604 .withName('Apple Collection')
605 .buildOrThrow();
606
607 const collectionM = new CollectionBuilder()
608 .withAuthorId(curator3.value)
609 .withName('Mango Collection')
610 .buildOrThrow();
611
612 collectionZ.addCard(card1.cardId, curator1);
613 collectionA.addCard(card2.cardId, curator2);
614 collectionM.addCard(card3.cardId, curator3);
615
616 await collectionRepository.save(collectionZ);
617 await collectionRepository.save(collectionA);
618 await collectionRepository.save(collectionM);
619
620 const result = await queryRepository.getCollectionsWithUrl(testUrl, {
621 page: 1,
622 limit: 10,
623 sortBy: CollectionSortField.NAME,
624 sortOrder: SortOrder.DESC,
625 });
626
627 expect(result.items).toHaveLength(3);
628 expect(result.items[0]!.name).toBe('Zebra Collection');
629 expect(result.items[1]!.name).toBe('Mango Collection');
630 expect(result.items[2]!.name).toBe('Apple Collection');
631 });
632 });
633
634 describe('Pagination', () => {
635 it('should paginate results correctly', async () => {
636 const testUrl = 'https://example.com/popular-article';
637 const url = URL.create(testUrl).unwrap();
638
639 // Create 5 cards with the same URL from different users
640 const cards = [];
641 const curators = [];
642 const collections = [];
643
644 for (let i = 1; i <= 5; i++) {
645 const curator = CuratorId.create(`did:plc:curator${i}`).unwrap();
646 curators.push(curator);
647
648 const card = new CardBuilder()
649 .withCuratorId(curator.value)
650 .withType(CardTypeEnum.URL)
651 .withUrl(url)
652 .buildOrThrow();
653
654 card.addToLibrary(curator);
655 cards.push(card);
656 await cardRepository.save(card);
657
658 // Create collection for each user
659 const collection = new CollectionBuilder()
660 .withAuthorId(curator.value)
661 .withName(`Collection ${i}`)
662 .buildOrThrow();
663
664 collection.addCard(card.cardId, curator);
665 collections.push(collection);
666 await collectionRepository.save(collection);
667 }
668
669 // Test first page with limit 2
670 const result1 = await queryRepository.getCollectionsWithUrl(testUrl, {
671 page: 1,
672 limit: 2,
673 sortBy: CollectionSortField.NAME,
674 sortOrder: SortOrder.ASC,
675 });
676
677 expect(result1.items).toHaveLength(2);
678 expect(result1.totalCount).toBe(5);
679 expect(result1.hasMore).toBe(true);
680
681 // Test second page
682 const result2 = await queryRepository.getCollectionsWithUrl(testUrl, {
683 page: 2,
684 limit: 2,
685 sortBy: CollectionSortField.NAME,
686 sortOrder: SortOrder.ASC,
687 });
688
689 expect(result2.items).toHaveLength(2);
690 expect(result2.totalCount).toBe(5);
691 expect(result2.hasMore).toBe(true);
692
693 // Test last page
694 const result3 = await queryRepository.getCollectionsWithUrl(testUrl, {
695 page: 3,
696 limit: 2,
697 sortBy: CollectionSortField.NAME,
698 sortOrder: SortOrder.ASC,
699 });
700
701 expect(result3.items).toHaveLength(1);
702 expect(result3.totalCount).toBe(5);
703 expect(result3.hasMore).toBe(false);
704
705 // Verify no duplicate entries across pages
706 const allCollectionIds = [
707 ...result1.items.map((c) => c.id),
708 ...result2.items.map((c) => c.id),
709 ...result3.items.map((c) => c.id),
710 ];
711 const uniqueCollectionIds = [...new Set(allCollectionIds)];
712 expect(uniqueCollectionIds).toHaveLength(5);
713 });
714
715 it('should handle empty pages correctly', async () => {
716 const testUrl = 'https://example.com/empty-test';
717
718 const result = await queryRepository.getCollectionsWithUrl(testUrl, {
719 page: 2,
720 limit: 10,
721 sortBy: CollectionSortField.NAME,
722 sortOrder: SortOrder.ASC,
723 });
724
725 expect(result.items).toHaveLength(0);
726 expect(result.totalCount).toBe(0);
727 expect(result.hasMore).toBe(false);
728 });
729
730 it('should handle large page numbers gracefully', async () => {
731 const testUrl = 'https://example.com/single-collection';
732 const url = URL.create(testUrl).unwrap();
733
734 // Create single card and collection
735 const card = new CardBuilder()
736 .withCuratorId(curator1.value)
737 .withType(CardTypeEnum.URL)
738 .withUrl(url)
739 .buildOrThrow();
740
741 card.addToLibrary(curator1);
742 await cardRepository.save(card);
743
744 const collection = new CollectionBuilder()
745 .withAuthorId(curator1.value)
746 .withName('Single Collection')
747 .buildOrThrow();
748
749 collection.addCard(card.cardId, curator1);
750 await collectionRepository.save(collection);
751
752 // Request page 10 when there's only 1 item
753 const result = await queryRepository.getCollectionsWithUrl(testUrl, {
754 page: 10,
755 limit: 10,
756 sortBy: CollectionSortField.NAME,
757 sortOrder: SortOrder.ASC,
758 });
759
760 expect(result.items).toHaveLength(0);
761 expect(result.totalCount).toBe(1);
762 expect(result.hasMore).toBe(false);
763 });
764 });
765});