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