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 { UniqueEntityID } from '../../../../shared/domain/UniqueEntityID'; 12import { 13 collections, 14 collectionCollaborators, 15 collectionCards, 16} from '../../infrastructure/repositories/schema/collection.sql'; 17import { cards } from '../../infrastructure/repositories/schema/card.sql'; 18import { libraryMemberships } from '../../infrastructure/repositories/schema/libraryMembership.sql'; 19import { publishedRecords } from '../../infrastructure/repositories/schema/publishedRecord.sql'; 20import { Collection, CollectionAccessType } from '../../domain/Collection'; 21import { CardFactory } from '../../domain/CardFactory'; 22import { CardTypeEnum } from '../../domain/value-objects/CardType'; 23import { 24 CollectionSortField, 25 SortOrder, 26} from '../../domain/ICollectionQueryRepository'; 27import { createTestSchema } from '../test-utils/createTestSchema'; 28import { CollectionBuilder } from '../utils/builders/CollectionBuilder'; 29import { FakeCollectionPublisher } from '../utils/FakeCollectionPublisher'; 30 31describe('DrizzleCollectionQueryRepository', () => { 32 let container: StartedPostgreSqlContainer; 33 let db: PostgresJsDatabase; 34 let queryRepository: DrizzleCollectionQueryRepository; 35 let collectionRepository: DrizzleCollectionRepository; 36 let cardRepository: DrizzleCardRepository; 37 let fakePublisher: FakeCollectionPublisher; 38 39 // Test data 40 let curatorId: CuratorId; 41 let otherCuratorId: CuratorId; 42 43 // Setup before all tests 44 beforeAll(async () => { 45 // Start PostgreSQL container 46 container = await new PostgreSqlContainer('postgres:14').start(); 47 48 // Create database connection 49 const connectionString = container.getConnectionUri(); 50 process.env.DATABASE_URL = connectionString; 51 const client = postgres(connectionString); 52 db = drizzle(client); 53 54 // Create repositories 55 queryRepository = new DrizzleCollectionQueryRepository(db); 56 collectionRepository = new DrizzleCollectionRepository(db); 57 cardRepository = new DrizzleCardRepository(db); 58 fakePublisher = new FakeCollectionPublisher(); 59 60 // Create schema using helper function 61 await createTestSchema(db); 62 63 // Create test data 64 curatorId = CuratorId.create('did:plc:testcurator').unwrap(); 65 otherCuratorId = CuratorId.create('did:plc:othercurator').unwrap(); 66 }, 60000); // Increase timeout for container startup 67 68 // Cleanup after all tests 69 afterAll(async () => { 70 // Stop container 71 await container.stop(); 72 }); 73 74 // Clear data between tests 75 beforeEach(async () => { 76 await db.delete(collectionCards); 77 await db.delete(collectionCollaborators); 78 await db.delete(collections); 79 await db.delete(libraryMemberships); 80 await db.delete(cards); 81 await db.delete(publishedRecords); 82 // Clear fake publisher state between tests 83 fakePublisher.clear(); 84 }); 85 86 describe('findByCreator', () => { 87 it('should return empty result when curator has no collections', async () => { 88 const result = await queryRepository.findByCreator(curatorId.value, { 89 page: 1, 90 limit: 10, 91 sortBy: CollectionSortField.UPDATED_AT, 92 sortOrder: SortOrder.DESC, 93 }); 94 95 expect(result.items).toHaveLength(0); 96 expect(result.totalCount).toBe(0); 97 expect(result.hasMore).toBe(false); 98 }); 99 100 it('should return collections for a curator', async () => { 101 // Create test collections 102 const collection1 = Collection.create( 103 { 104 authorId: curatorId, 105 name: 'First Collection', 106 description: 'First description', 107 accessType: CollectionAccessType.OPEN, 108 collaboratorIds: [], 109 createdAt: new Date('2023-01-01'), 110 updatedAt: new Date('2023-01-01'), 111 }, 112 new UniqueEntityID(), 113 ).unwrap(); 114 115 const collection2 = Collection.create( 116 { 117 authorId: curatorId, 118 name: 'Second Collection', 119 accessType: CollectionAccessType.CLOSED, 120 collaboratorIds: [], 121 createdAt: new Date('2023-01-02'), 122 updatedAt: new Date('2023-01-02'), 123 }, 124 new UniqueEntityID(), 125 ).unwrap(); 126 127 // Save collections 128 await collectionRepository.save(collection1); 129 await collectionRepository.save(collection2); 130 131 // Query collections 132 const result = await queryRepository.findByCreator(curatorId.value, { 133 page: 1, 134 limit: 10, 135 sortBy: CollectionSortField.UPDATED_AT, 136 sortOrder: SortOrder.DESC, 137 }); 138 139 expect(result.items).toHaveLength(2); 140 expect(result.totalCount).toBe(2); 141 expect(result.hasMore).toBe(false); 142 143 // Check collection data 144 const names = result.items.map((item) => item.name); 145 expect(names).toContain('First Collection'); 146 expect(names).toContain('Second Collection'); 147 148 // Check that all items have the correct curator 149 result.items.forEach((item) => { 150 expect(item.authorId).toBe(curatorId.value); 151 }); 152 }); 153 154 it('should not return collections from other curators', async () => { 155 // Create collection for other curator 156 const otherCollection = Collection.create( 157 { 158 authorId: otherCuratorId, 159 name: "Other's Collection", 160 accessType: CollectionAccessType.OPEN, 161 collaboratorIds: [], 162 createdAt: new Date(), 163 updatedAt: new Date(), 164 }, 165 new UniqueEntityID(), 166 ).unwrap(); 167 168 await collectionRepository.save(otherCollection); 169 170 // Query collections for our curator 171 const result = await queryRepository.findByCreator(curatorId.value, { 172 page: 1, 173 limit: 10, 174 sortBy: CollectionSortField.UPDATED_AT, 175 sortOrder: SortOrder.DESC, 176 }); 177 178 expect(result.items).toHaveLength(0); 179 expect(result.totalCount).toBe(0); 180 }); 181 182 it('should include card count for collections', async () => { 183 // Create a card 184 const cardResult = CardFactory.create({ 185 curatorId: curatorId.value, 186 cardInput: { 187 type: CardTypeEnum.NOTE, 188 text: 'Test card', 189 }, 190 }); 191 const card = cardResult.unwrap(); 192 await cardRepository.save(card); 193 194 // Create collection with cards 195 const collection = Collection.create( 196 { 197 authorId: curatorId, 198 name: 'Collection with Cards', 199 accessType: CollectionAccessType.OPEN, 200 collaboratorIds: [], 201 createdAt: new Date(), 202 updatedAt: new Date(), 203 }, 204 new UniqueEntityID(), 205 ).unwrap(); 206 207 // Add card to collection 208 collection.addCard(card.cardId, curatorId); 209 await collectionRepository.save(collection); 210 211 // Create collection without cards 212 const emptyCollection = Collection.create( 213 { 214 authorId: curatorId, 215 name: 'Empty Collection', 216 accessType: CollectionAccessType.OPEN, 217 collaboratorIds: [], 218 createdAt: new Date(), 219 updatedAt: new Date(), 220 }, 221 new UniqueEntityID(), 222 ).unwrap(); 223 224 await collectionRepository.save(emptyCollection); 225 226 // Query collections 227 const result = await queryRepository.findByCreator(curatorId.value, { 228 page: 1, 229 limit: 10, 230 sortBy: CollectionSortField.UPDATED_AT, 231 sortOrder: SortOrder.DESC, 232 }); 233 234 expect(result.items).toHaveLength(2); 235 236 // Find the collections by name and check card counts 237 const collectionWithCards = result.items.find( 238 (item) => item.name === 'Collection with Cards', 239 ); 240 const collectionWithoutCards = result.items.find( 241 (item) => item.name === 'Empty Collection', 242 ); 243 244 expect(collectionWithCards?.cardCount).toBe(1); 245 expect(collectionWithoutCards?.cardCount).toBe(0); 246 }); 247 }); 248 249 describe('sorting', () => { 250 beforeEach(async () => { 251 // Create test collections with different properties for sorting 252 const collection1 = Collection.create( 253 { 254 authorId: curatorId, 255 name: 'Alpha Collection', 256 description: 'First alphabetically', 257 accessType: CollectionAccessType.OPEN, 258 collaboratorIds: [], 259 createdAt: new Date('2023-01-01T10:00:00Z'), 260 updatedAt: new Date('2023-01-03T10:00:00Z'), // Most recently updated 261 }, 262 new UniqueEntityID(), 263 ).unwrap(); 264 265 const collection2 = Collection.create( 266 { 267 authorId: curatorId, 268 name: 'Beta Collection', 269 description: 'Second alphabetically', 270 accessType: CollectionAccessType.OPEN, 271 collaboratorIds: [], 272 createdAt: new Date('2023-01-02T10:00:00Z'), // Most recently created 273 updatedAt: new Date('2023-01-02T10:00:00Z'), 274 }, 275 new UniqueEntityID(), 276 ).unwrap(); 277 278 const collection3 = Collection.create( 279 { 280 authorId: curatorId, 281 name: 'Gamma Collection', 282 description: 'Third alphabetically', 283 accessType: CollectionAccessType.OPEN, 284 collaboratorIds: [], 285 createdAt: new Date('2023-01-01T09:00:00Z'), // Oldest created 286 updatedAt: new Date('2023-01-01T09:00:00Z'), // Oldest updated 287 }, 288 new UniqueEntityID(), 289 ).unwrap(); 290 291 // Save collections 292 await collectionRepository.save(collection1); 293 await collectionRepository.save(collection2); 294 await collectionRepository.save(collection3); 295 296 // Create cards and add different numbers to collections for card count sorting 297 const card1 = CardFactory.create({ 298 curatorId: curatorId.value, 299 cardInput: { type: CardTypeEnum.NOTE, text: 'Card 1' }, 300 }).unwrap(); 301 const card2 = CardFactory.create({ 302 curatorId: curatorId.value, 303 cardInput: { type: CardTypeEnum.NOTE, text: 'Card 2' }, 304 }).unwrap(); 305 const card3 = CardFactory.create({ 306 curatorId: curatorId.value, 307 cardInput: { type: CardTypeEnum.NOTE, text: 'Card 3' }, 308 }).unwrap(); 309 310 await cardRepository.save(card1); 311 await cardRepository.save(card2); 312 await cardRepository.save(card3); 313 314 // Add cards to collections: Gamma gets 3 cards, Beta gets 1, Alpha gets 0 315 collection3.addCard(card1.cardId, curatorId); 316 collection3.addCard(card2.cardId, curatorId); 317 collection3.addCard(card3.cardId, curatorId); 318 collection2.addCard(card1.cardId, curatorId); 319 320 await collectionRepository.save(collection2); 321 await collectionRepository.save(collection3); 322 }); 323 324 it('should sort by name ascending', async () => { 325 const result = await queryRepository.findByCreator(curatorId.value, { 326 page: 1, 327 limit: 10, 328 sortBy: CollectionSortField.NAME, 329 sortOrder: SortOrder.ASC, 330 }); 331 332 expect(result.items).toHaveLength(3); 333 expect(result.items[0]?.name).toBe('Alpha Collection'); 334 expect(result.items[1]?.name).toBe('Beta Collection'); 335 expect(result.items[2]?.name).toBe('Gamma Collection'); 336 }); 337 338 it('should sort by name descending', async () => { 339 const result = await queryRepository.findByCreator(curatorId.value, { 340 page: 1, 341 limit: 10, 342 sortBy: CollectionSortField.NAME, 343 sortOrder: SortOrder.DESC, 344 }); 345 346 expect(result.items).toHaveLength(3); 347 expect(result.items[0]?.name).toBe('Gamma Collection'); 348 expect(result.items[1]?.name).toBe('Beta Collection'); 349 expect(result.items[2]?.name).toBe('Alpha Collection'); 350 }); 351 352 it('should sort by created date ascending', async () => { 353 const result = await queryRepository.findByCreator(curatorId.value, { 354 page: 1, 355 limit: 10, 356 sortBy: CollectionSortField.CREATED_AT, 357 sortOrder: SortOrder.ASC, 358 }); 359 360 expect(result.items).toHaveLength(3); 361 expect(result.items[0]?.name).toBe('Gamma Collection'); // Oldest 362 expect(result.items[1]?.name).toBe('Alpha Collection'); 363 expect(result.items[2]?.name).toBe('Beta Collection'); // Newest 364 }); 365 366 it('should sort by created date descending', async () => { 367 const result = await queryRepository.findByCreator(curatorId.value, { 368 page: 1, 369 limit: 10, 370 sortBy: CollectionSortField.CREATED_AT, 371 sortOrder: SortOrder.DESC, 372 }); 373 374 expect(result.items).toHaveLength(3); 375 expect(result.items[0]?.name).toBe('Beta Collection'); // Newest 376 expect(result.items[1]?.name).toBe('Alpha Collection'); 377 expect(result.items[2]?.name).toBe('Gamma Collection'); // Oldest 378 }); 379 380 it('should sort by updated date ascending', async () => { 381 const result = await queryRepository.findByCreator(curatorId.value, { 382 page: 1, 383 limit: 10, 384 sortBy: CollectionSortField.UPDATED_AT, 385 sortOrder: SortOrder.ASC, 386 }); 387 388 expect(result.items).toHaveLength(3); 389 expect(result.items[0]!.updatedAt.getTime()).toBeLessThanOrEqual( 390 result.items[1]!.updatedAt.getTime(), 391 ); 392 expect(result.items[1]!.updatedAt.getTime()).toBeLessThanOrEqual( 393 result.items[2]!.updatedAt.getTime(), 394 ); 395 }); 396 397 it('should sort by updated date descending (default)', async () => { 398 const result = await queryRepository.findByCreator(curatorId.value, { 399 page: 1, 400 limit: 10, 401 sortBy: CollectionSortField.UPDATED_AT, 402 sortOrder: SortOrder.DESC, 403 }); 404 405 expect(result.items).toHaveLength(3); 406 expect(result.items[0]!.updatedAt.getTime()).toBeGreaterThanOrEqual( 407 result.items[1]!.updatedAt.getTime(), 408 ); 409 expect(result.items[1]!.updatedAt.getTime()).toBeGreaterThanOrEqual( 410 result.items[2]!.updatedAt.getTime(), 411 ); 412 }); 413 414 it('should sort by card count ascending', async () => { 415 const result = await queryRepository.findByCreator(curatorId.value, { 416 page: 1, 417 limit: 10, 418 sortBy: CollectionSortField.CARD_COUNT, 419 sortOrder: SortOrder.ASC, 420 }); 421 422 expect(result.items).toHaveLength(3); 423 expect(result.items[0]?.name).toBe('Alpha Collection'); // 0 cards 424 expect(result.items[0]?.cardCount).toBe(0); 425 expect(result.items[1]?.name).toBe('Beta Collection'); // 1 card 426 expect(result.items[1]?.cardCount).toBe(1); 427 expect(result.items[2]?.name).toBe('Gamma Collection'); // 3 cards 428 expect(result.items[2]?.cardCount).toBe(3); 429 }); 430 431 it('should sort by card count descending', async () => { 432 const result = await queryRepository.findByCreator(curatorId.value, { 433 page: 1, 434 limit: 10, 435 sortBy: CollectionSortField.CARD_COUNT, 436 sortOrder: SortOrder.DESC, 437 }); 438 439 expect(result.items).toHaveLength(3); 440 expect(result.items[0]?.name).toBe('Gamma Collection'); // 3 cards 441 expect(result.items[0]?.cardCount).toBe(3); 442 expect(result.items[1]?.name).toBe('Beta Collection'); // 1 card 443 expect(result.items[1]?.cardCount).toBe(1); 444 expect(result.items[2]?.name).toBe('Alpha Collection'); // 0 cards 445 expect(result.items[2]?.cardCount).toBe(0); 446 }); 447 }); 448 449 describe('pagination', () => { 450 beforeEach(async () => { 451 // Create 5 test collections for pagination testing 452 for (let i = 1; i <= 5; i++) { 453 const collection = Collection.create( 454 { 455 authorId: curatorId, 456 name: `Collection ${i.toString().padStart(2, '0')}`, 457 description: `Description ${i}`, 458 accessType: CollectionAccessType.OPEN, 459 collaboratorIds: [], 460 createdAt: new Date(`2023-01-${i.toString().padStart(2, '0')}`), 461 updatedAt: new Date(`2023-01-${i.toString().padStart(2, '0')}`), 462 }, 463 new UniqueEntityID(), 464 ).unwrap(); 465 466 await collectionRepository.save(collection); 467 } 468 }); 469 470 it('should handle first page with limit', async () => { 471 const result = await queryRepository.findByCreator(curatorId.value, { 472 page: 1, 473 limit: 2, 474 sortBy: CollectionSortField.NAME, 475 sortOrder: SortOrder.ASC, 476 }); 477 478 expect(result.items).toHaveLength(2); 479 expect(result.totalCount).toBe(5); 480 expect(result.hasMore).toBe(true); 481 expect(result.items[0]?.name).toBe('Collection 01'); 482 expect(result.items[1]?.name).toBe('Collection 02'); 483 }); 484 485 it('should handle second page', async () => { 486 const result = await queryRepository.findByCreator(curatorId.value, { 487 page: 2, 488 limit: 2, 489 sortBy: CollectionSortField.NAME, 490 sortOrder: SortOrder.ASC, 491 }); 492 493 expect(result.items).toHaveLength(2); 494 expect(result.totalCount).toBe(5); 495 expect(result.hasMore).toBe(true); 496 expect(result.items[0]?.name).toBe('Collection 03'); 497 expect(result.items[1]?.name).toBe('Collection 04'); 498 }); 499 500 it('should handle last page with remaining items', async () => { 501 const result = await queryRepository.findByCreator(curatorId.value, { 502 page: 3, 503 limit: 2, 504 sortBy: CollectionSortField.NAME, 505 sortOrder: SortOrder.ASC, 506 }); 507 508 expect(result.items).toHaveLength(1); 509 expect(result.totalCount).toBe(5); 510 expect(result.hasMore).toBe(false); 511 expect(result.items[0]?.name).toBe('Collection 05'); 512 }); 513 514 it('should handle page beyond available data', async () => { 515 const result = await queryRepository.findByCreator(curatorId.value, { 516 page: 10, 517 limit: 2, 518 sortBy: CollectionSortField.NAME, 519 sortOrder: SortOrder.ASC, 520 }); 521 522 expect(result.items).toHaveLength(0); 523 expect(result.totalCount).toBe(5); 524 expect(result.hasMore).toBe(false); 525 }); 526 527 it('should handle large limit that exceeds total items', async () => { 528 const result = await queryRepository.findByCreator(curatorId.value, { 529 page: 1, 530 limit: 100, 531 sortBy: CollectionSortField.NAME, 532 sortOrder: SortOrder.ASC, 533 }); 534 535 expect(result.items).toHaveLength(5); 536 expect(result.totalCount).toBe(5); 537 expect(result.hasMore).toBe(false); 538 }); 539 540 it('should calculate hasMore correctly for exact page boundaries', async () => { 541 // Test when items exactly fill pages 542 const result = await queryRepository.findByCreator(curatorId.value, { 543 page: 1, 544 limit: 5, // Exactly matches total count 545 sortBy: CollectionSortField.NAME, 546 sortOrder: SortOrder.ASC, 547 }); 548 549 expect(result.items).toHaveLength(5); 550 expect(result.totalCount).toBe(5); 551 expect(result.hasMore).toBe(false); 552 }); 553 }); 554 555 describe('combined sorting and pagination', () => { 556 beforeEach(async () => { 557 // Create collections with different update times and card counts 558 const collections = [ 559 { 560 name: 'Alpha', 561 updatedAt: new Date('2023-01-01'), 562 cardCount: 3, 563 }, 564 { 565 name: 'Beta', 566 updatedAt: new Date('2023-01-03'), 567 cardCount: 1, 568 }, 569 { 570 name: 'Gamma', 571 updatedAt: new Date('2023-01-02'), 572 cardCount: 2, 573 }, 574 { 575 name: 'Delta', 576 updatedAt: new Date('2023-01-04'), 577 cardCount: 0, 578 }, 579 ]; 580 581 for (const collectionData of collections) { 582 const collection = Collection.create( 583 { 584 authorId: curatorId, 585 name: collectionData.name, 586 accessType: CollectionAccessType.OPEN, 587 collaboratorIds: [], 588 createdAt: new Date(), 589 updatedAt: collectionData.updatedAt, 590 }, 591 new UniqueEntityID(), 592 ).unwrap(); 593 594 await collectionRepository.save(collection); 595 596 // Add cards to match expected card count 597 for (let i = 0; i < collectionData.cardCount; i++) { 598 const card = CardFactory.create({ 599 curatorId: curatorId.value, 600 cardInput: { 601 type: CardTypeEnum.NOTE, 602 text: `Card ${i} for ${collectionData.name}`, 603 }, 604 }).unwrap(); 605 606 await cardRepository.save(card); 607 collection.addCard(card.cardId, curatorId); 608 } 609 610 if (collectionData.cardCount > 0) { 611 await collectionRepository.save(collection); 612 } 613 } 614 }); 615 616 it('should combine sorting by updated date desc with pagination', async () => { 617 // First page - should get Delta (newest) and Beta 618 const page1 = await queryRepository.findByCreator(curatorId.value, { 619 page: 1, 620 limit: 2, 621 sortBy: CollectionSortField.UPDATED_AT, 622 sortOrder: SortOrder.DESC, 623 }); 624 625 expect(page1.items).toHaveLength(2); 626 expect(page1.items[0]!.updatedAt.getTime()).toBeGreaterThanOrEqual( 627 page1.items[1]!.updatedAt.getTime(), 628 ); 629 expect(page1.hasMore).toBe(true); 630 631 // Second page - should get Gamma and Alpha 632 const page2 = await queryRepository.findByCreator(curatorId.value, { 633 page: 2, 634 limit: 2, 635 sortBy: CollectionSortField.UPDATED_AT, 636 sortOrder: SortOrder.DESC, 637 }); 638 639 expect(page2.items).toHaveLength(2); 640 expect(page2.items[0]!.updatedAt.getTime()).toBeGreaterThanOrEqual( 641 page2.items[1]!.updatedAt.getTime(), 642 ); 643 expect(page2.hasMore).toBe(false); 644 }); 645 646 it('should combine sorting by card count desc with pagination', async () => { 647 // First page - should get Alpha (3 cards) and Gamma (2 cards) 648 const page1 = await queryRepository.findByCreator(curatorId.value, { 649 page: 1, 650 limit: 2, 651 sortBy: CollectionSortField.CARD_COUNT, 652 sortOrder: SortOrder.DESC, 653 }); 654 655 expect(page1.items).toHaveLength(2); 656 expect(page1.items[0]?.name).toBe('Alpha'); 657 expect(page1.items[0]?.cardCount).toBe(3); 658 expect(page1.items[1]?.name).toBe('Gamma'); 659 expect(page1.items[1]?.cardCount).toBe(2); 660 expect(page1.hasMore).toBe(true); 661 662 // Second page - should get Beta (1 card) and Delta (0 cards) 663 const page2 = await queryRepository.findByCreator(curatorId.value, { 664 page: 2, 665 limit: 2, 666 sortBy: CollectionSortField.CARD_COUNT, 667 sortOrder: SortOrder.DESC, 668 }); 669 670 expect(page2.items).toHaveLength(2); 671 expect(page2.items[0]?.name).toBe('Beta'); 672 expect(page2.items[0]?.cardCount).toBe(1); 673 expect(page2.items[1]?.name).toBe('Delta'); 674 expect(page2.items[1]?.cardCount).toBe(0); 675 expect(page2.hasMore).toBe(false); 676 }); 677 }); 678 679 describe('text search', () => { 680 beforeEach(async () => { 681 // Create collections with different names and descriptions for search testing 682 const collections = [ 683 { 684 name: 'Machine Learning Papers', 685 description: 'Collection of AI and ML research papers', 686 }, 687 { 688 name: 'Web Development', 689 description: 'Frontend and backend development resources', 690 }, 691 { 692 name: 'Data Science', 693 description: 'Statistics, machine learning, and data analysis', 694 }, 695 { 696 name: 'JavaScript Tutorials', 697 description: 'Learning resources for JS development', 698 }, 699 { 700 name: 'Python Scripts', 701 description: 'Useful Python automation and data scripts', 702 }, 703 { 704 name: 'No Description Collection', 705 description: undefined, // No description 706 }, 707 ]; 708 709 for (const collectionData of collections) { 710 const collection = Collection.create( 711 { 712 authorId: curatorId, 713 name: collectionData.name, 714 description: collectionData.description, 715 accessType: CollectionAccessType.OPEN, 716 collaboratorIds: [], 717 createdAt: new Date(), 718 updatedAt: new Date(), 719 }, 720 new UniqueEntityID(), 721 ).unwrap(); 722 723 await collectionRepository.save(collection); 724 } 725 }); 726 727 it('should return all collections when no search text provided', async () => { 728 const result = await queryRepository.findByCreator(curatorId.value, { 729 page: 1, 730 limit: 10, 731 sortBy: CollectionSortField.NAME, 732 sortOrder: SortOrder.ASC, 733 }); 734 735 expect(result.items).toHaveLength(6); 736 expect(result.totalCount).toBe(6); 737 }); 738 739 it('should search by collection name (case insensitive)', async () => { 740 const result = await queryRepository.findByCreator(curatorId.value, { 741 page: 1, 742 limit: 10, 743 sortBy: CollectionSortField.NAME, 744 sortOrder: SortOrder.ASC, 745 searchText: 'MACHINE', 746 }); 747 748 expect(result.items).toHaveLength(2); 749 expect(result.totalCount).toBe(2); 750 }); 751 752 it('should search by collection description', async () => { 753 const result = await queryRepository.findByCreator(curatorId.value, { 754 page: 1, 755 limit: 10, 756 sortBy: CollectionSortField.NAME, 757 sortOrder: SortOrder.ASC, 758 searchText: 'development', 759 }); 760 761 expect(result.items).toHaveLength(2); 762 expect(result.totalCount).toBe(2); 763 764 const names = result.items.map((item) => item.name).sort(); 765 expect(names).toEqual(['JavaScript Tutorials', 'Web Development']); 766 }); 767 768 it('should search across both name and description', async () => { 769 const result = await queryRepository.findByCreator(curatorId.value, { 770 page: 1, 771 limit: 10, 772 sortBy: CollectionSortField.NAME, 773 sortOrder: SortOrder.ASC, 774 searchText: 'python', 775 }); 776 777 expect(result.items).toHaveLength(1); 778 expect(result.items[0]?.name).toBe('Python Scripts'); 779 expect(result.totalCount).toBe(1); 780 }); 781 782 it('should return multiple matches for broad search terms', async () => { 783 const result = await queryRepository.findByCreator(curatorId.value, { 784 page: 1, 785 limit: 10, 786 sortBy: CollectionSortField.NAME, 787 sortOrder: SortOrder.ASC, 788 searchText: 'learning', 789 }); 790 791 expect(result.items).toHaveLength(3); 792 expect(result.totalCount).toBe(3); 793 }); 794 795 it('should return empty results for non-matching search', async () => { 796 const result = await queryRepository.findByCreator(curatorId.value, { 797 page: 1, 798 limit: 10, 799 sortBy: CollectionSortField.NAME, 800 sortOrder: SortOrder.ASC, 801 searchText: 'nonexistent', 802 }); 803 804 expect(result.items).toHaveLength(0); 805 expect(result.totalCount).toBe(0); 806 expect(result.hasMore).toBe(false); 807 }); 808 809 it('should handle empty search text as no filter', async () => { 810 const result = await queryRepository.findByCreator(curatorId.value, { 811 page: 1, 812 limit: 10, 813 sortBy: CollectionSortField.NAME, 814 sortOrder: SortOrder.ASC, 815 searchText: '', 816 }); 817 818 expect(result.items).toHaveLength(6); 819 expect(result.totalCount).toBe(6); 820 }); 821 822 it('should handle whitespace-only search text as no filter', async () => { 823 const result = await queryRepository.findByCreator(curatorId.value, { 824 page: 1, 825 limit: 10, 826 sortBy: CollectionSortField.NAME, 827 sortOrder: SortOrder.ASC, 828 searchText: ' ', 829 }); 830 831 expect(result.items).toHaveLength(6); 832 expect(result.totalCount).toBe(6); 833 }); 834 835 it('should combine search with pagination', async () => { 836 const result = await queryRepository.findByCreator(curatorId.value, { 837 page: 1, 838 limit: 1, 839 sortBy: CollectionSortField.NAME, 840 sortOrder: SortOrder.ASC, 841 searchText: 'learning', 842 }); 843 844 expect(result.items).toHaveLength(1); 845 expect(result.totalCount).toBe(3); 846 expect(result.hasMore).toBe(true); 847 }); 848 849 it('should combine search with sorting by name desc', async () => { 850 const result = await queryRepository.findByCreator(curatorId.value, { 851 page: 1, 852 limit: 10, 853 sortBy: CollectionSortField.NAME, 854 sortOrder: SortOrder.DESC, 855 searchText: 'learning', 856 }); 857 858 expect(result.items).toHaveLength(3); 859 }); 860 861 it('should search collections with null descriptions', async () => { 862 const result = await queryRepository.findByCreator(curatorId.value, { 863 page: 1, 864 limit: 10, 865 sortBy: CollectionSortField.NAME, 866 sortOrder: SortOrder.ASC, 867 searchText: 'description', 868 }); 869 870 expect(result.items).toHaveLength(1); 871 expect(result.items[0]?.name).toBe('No Description Collection'); 872 expect(result.totalCount).toBe(1); 873 }); 874 875 it('should handle special characters in search text', async () => { 876 // Create a collection with special characters 877 const collection = Collection.create( 878 { 879 authorId: curatorId, 880 name: 'C++ Programming', 881 description: 'Advanced C++ & system programming', 882 accessType: CollectionAccessType.OPEN, 883 collaboratorIds: [], 884 createdAt: new Date(), 885 updatedAt: new Date(), 886 }, 887 new UniqueEntityID(), 888 ).unwrap(); 889 890 await collectionRepository.save(collection); 891 892 const result = await queryRepository.findByCreator(curatorId.value, { 893 page: 1, 894 limit: 10, 895 sortBy: CollectionSortField.NAME, 896 sortOrder: SortOrder.ASC, 897 searchText: 'C++', 898 }); 899 900 expect(result.items).toHaveLength(1); 901 expect(result.items[0]?.name).toBe('C++ Programming'); 902 }); 903 904 it('should handle partial word matches', async () => { 905 const result = await queryRepository.findByCreator(curatorId.value, { 906 page: 1, 907 limit: 10, 908 sortBy: CollectionSortField.NAME, 909 sortOrder: SortOrder.ASC, 910 searchText: 'script', 911 }); 912 913 expect(result.items).toHaveLength(3); 914 expect(result.totalCount).toBe(3); 915 }); 916 917 it('should not return collections from other curators in search', async () => { 918 // Create collection for other curator 919 const otherCollection = Collection.create( 920 { 921 authorId: otherCuratorId, 922 name: 'Other Machine Learning', 923 description: 'Another ML collection', 924 accessType: CollectionAccessType.OPEN, 925 collaboratorIds: [], 926 createdAt: new Date(), 927 updatedAt: new Date(), 928 }, 929 new UniqueEntityID(), 930 ).unwrap(); 931 932 await collectionRepository.save(otherCollection); 933 934 const result = await queryRepository.findByCreator(curatorId.value, { 935 page: 1, 936 limit: 10, 937 sortBy: CollectionSortField.NAME, 938 sortOrder: SortOrder.ASC, 939 searchText: 'machine', 940 }); 941 942 expect(result.items).toHaveLength(2); 943 }); 944 }); 945 946 describe('published record URIs', () => { 947 it('should return empty string for collections without published records', async () => { 948 const collection = Collection.create( 949 { 950 authorId: curatorId, 951 name: 'Unpublished Collection', 952 accessType: CollectionAccessType.OPEN, 953 collaboratorIds: [], 954 createdAt: new Date(), 955 updatedAt: new Date(), 956 }, 957 new UniqueEntityID(), 958 ).unwrap(); 959 960 await collectionRepository.save(collection); 961 962 const result = await queryRepository.findByCreator(curatorId.value, { 963 page: 1, 964 limit: 10, 965 sortBy: CollectionSortField.UPDATED_AT, 966 sortOrder: SortOrder.DESC, 967 }); 968 969 expect(result.items).toHaveLength(1); 970 expect(result.items[0]?.uri).toBeUndefined(); 971 }); 972 973 it('should return URI for collections with published records', async () => { 974 const testUri = 975 'at://did:plc:testcurator/network.cosmik.collection/test123'; 976 const testCid = 'bafytest123'; 977 978 // Create collection using builder 979 const collection = new CollectionBuilder() 980 .withAuthorId(curatorId.value) 981 .withName('Published Collection') 982 .withAccessType(CollectionAccessType.OPEN) 983 .buildOrThrow(); 984 985 // Publish the collection using the fake publisher 986 const publishResult = await fakePublisher.publish(collection); 987 expect(publishResult.isOk()).toBe(true); 988 989 const publishedRecordId = publishResult.unwrap(); 990 991 // Mark the collection as published in the domain model 992 collection.markAsPublished(publishedRecordId); 993 994 // Save the collection 995 await collectionRepository.save(collection); 996 997 const result = await queryRepository.findByCreator(curatorId.value, { 998 page: 1, 999 limit: 10, 1000 sortBy: CollectionSortField.UPDATED_AT, 1001 sortOrder: SortOrder.DESC, 1002 }); 1003 1004 expect(result.items).toHaveLength(1); 1005 expect(result.items[0]?.uri).toBe(publishedRecordId.uri); 1006 expect(result.items[0]?.name).toBe('Published Collection'); 1007 }); 1008 1009 it('should handle mix of published and unpublished collections', async () => { 1010 // Create published collection using builder 1011 const publishedCollection = new CollectionBuilder() 1012 .withAuthorId(curatorId.value) 1013 .withName('Published Collection') 1014 .withAccessType(CollectionAccessType.OPEN) 1015 .withCreatedAt(new Date('2023-01-01')) 1016 .withUpdatedAt(new Date('2023-01-01')) 1017 .buildOrThrow(); 1018 1019 // Create unpublished collection using builder 1020 const unpublishedCollection = new CollectionBuilder() 1021 .withAuthorId(curatorId.value) 1022 .withName('Unpublished Collection') 1023 .withAccessType(CollectionAccessType.OPEN) 1024 .withCreatedAt(new Date('2023-01-02')) 1025 .withUpdatedAt(new Date('2023-01-02')) 1026 .buildOrThrow(); 1027 1028 // Publish the first collection using the fake publisher 1029 const publishResult = await fakePublisher.publish(publishedCollection); 1030 expect(publishResult.isOk()).toBe(true); 1031 1032 const publishedRecordId = publishResult.unwrap(); 1033 1034 // Mark the collection as published in the domain model 1035 publishedCollection.markAsPublished(publishedRecordId); 1036 1037 // Save both collections 1038 await collectionRepository.save(publishedCollection); 1039 await collectionRepository.save(unpublishedCollection); 1040 1041 const result = await queryRepository.findByCreator(curatorId.value, { 1042 page: 1, 1043 limit: 10, 1044 sortBy: CollectionSortField.UPDATED_AT, 1045 sortOrder: SortOrder.DESC, 1046 }); 1047 1048 expect(result.items).toHaveLength(2); 1049 1050 // Find collections by name and check URIs 1051 const publishedItem = result.items.find( 1052 (item) => item.name === 'Published Collection', 1053 ); 1054 const unpublishedItem = result.items.find( 1055 (item) => item.name === 'Unpublished Collection', 1056 ); 1057 1058 expect(publishedItem?.uri).toBe(publishedRecordId.uri); 1059 expect(unpublishedItem?.uri).toBeUndefined(); 1060 }); 1061 }); 1062 1063 describe('edge cases', () => { 1064 it('should handle curator with no collections gracefully', async () => { 1065 const result = await queryRepository.findByCreator( 1066 'did:plc:nonexistent', 1067 { 1068 page: 1, 1069 limit: 10, 1070 sortBy: CollectionSortField.UPDATED_AT, 1071 sortOrder: SortOrder.DESC, 1072 }, 1073 ); 1074 1075 expect(result.items).toHaveLength(0); 1076 expect(result.totalCount).toBe(0); 1077 expect(result.hasMore).toBe(false); 1078 }); 1079 1080 it('should handle collections with null descriptions', async () => { 1081 const collection = Collection.create( 1082 { 1083 authorId: curatorId, 1084 name: 'No Description Collection', 1085 // No description provided 1086 accessType: CollectionAccessType.OPEN, 1087 collaboratorIds: [], 1088 createdAt: new Date(), 1089 updatedAt: new Date(), 1090 }, 1091 new UniqueEntityID(), 1092 ).unwrap(); 1093 1094 await collectionRepository.save(collection); 1095 1096 const result = await queryRepository.findByCreator(curatorId.value, { 1097 page: 1, 1098 limit: 10, 1099 sortBy: CollectionSortField.UPDATED_AT, 1100 sortOrder: SortOrder.DESC, 1101 }); 1102 1103 expect(result.items).toHaveLength(1); 1104 expect(result.items[0]?.description).toBeUndefined(); 1105 }); 1106 1107 it('should handle very large page numbers', async () => { 1108 const collection = Collection.create( 1109 { 1110 authorId: curatorId, 1111 name: 'Single Collection', 1112 accessType: CollectionAccessType.OPEN, 1113 collaboratorIds: [], 1114 createdAt: new Date(), 1115 updatedAt: new Date(), 1116 }, 1117 new UniqueEntityID(), 1118 ).unwrap(); 1119 1120 await collectionRepository.save(collection); 1121 1122 const result = await queryRepository.findByCreator(curatorId.value, { 1123 page: 999999, 1124 limit: 10, 1125 sortBy: CollectionSortField.UPDATED_AT, 1126 sortOrder: SortOrder.DESC, 1127 }); 1128 1129 expect(result.items).toHaveLength(0); 1130 expect(result.totalCount).toBe(1); 1131 expect(result.hasMore).toBe(false); 1132 }); 1133 }); 1134});