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