A social knowledge tool for researchers built on ATProto
at main 674 lines 22 kB view raw
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 { CuratorId } from '../../domain/value-objects/CuratorId'; 10import { cards } from '../../infrastructure/repositories/schema/card.sql'; 11import { libraryMemberships } from '../../infrastructure/repositories/schema/libraryMembership.sql'; 12import { publishedRecords } from '../../infrastructure/repositories/schema/publishedRecord.sql'; 13import { CardBuilder } from '../utils/builders/CardBuilder'; 14import { URL } from '../../domain/value-objects/URL'; 15import { CardSortField, SortOrder } from '../../domain/ICardQueryRepository'; 16import { createTestSchema } from '../test-utils/createTestSchema'; 17import { CardTypeEnum } from '../../domain/value-objects/CardType'; 18import { PublishedRecordId } from '../../domain/value-objects/PublishedRecordId'; 19 20describe('DrizzleCardQueryRepository - getLibrariesForUrl', () => { 21 let container: StartedPostgreSqlContainer; 22 let db: PostgresJsDatabase; 23 let queryRepository: DrizzleCardQueryRepository; 24 let cardRepository: DrizzleCardRepository; 25 26 // Test data 27 let curator1: CuratorId; 28 let curator2: CuratorId; 29 let curator3: CuratorId; 30 31 // Setup before all tests 32 beforeAll(async () => { 33 // Start PostgreSQL container 34 container = await new PostgreSqlContainer('postgres:14').start(); 35 36 // Create database connection 37 const connectionString = container.getConnectionUri(); 38 process.env.DATABASE_URL = connectionString; 39 const client = postgres(connectionString); 40 db = drizzle(client); 41 42 // Create repositories 43 queryRepository = new DrizzleCardQueryRepository(db); 44 cardRepository = new DrizzleCardRepository(db); 45 46 // Create schema using helper function 47 await createTestSchema(db); 48 49 // Create test data 50 curator1 = CuratorId.create('did:plc:curator1').unwrap(); 51 curator2 = CuratorId.create('did:plc:curator2').unwrap(); 52 curator3 = CuratorId.create('did:plc:curator3').unwrap(); 53 }, 60000); // Increase timeout for container startup 54 55 // Cleanup after all tests 56 afterAll(async () => { 57 // Stop container 58 await container.stop(); 59 }); 60 61 // Clear data between tests 62 beforeEach(async () => { 63 await db.delete(libraryMemberships); 64 await db.delete(cards); 65 await db.delete(publishedRecords); 66 }); 67 68 describe('getLibrariesForUrl', () => { 69 it('should return all users who have cards with the specified URL', async () => { 70 const testUrl = 'https://example.com/shared-article'; 71 const url = URL.create(testUrl).unwrap(); 72 73 // Create URL cards for different users with the same URL 74 const card1 = new CardBuilder() 75 .withCuratorId(curator1.value) 76 .withType(CardTypeEnum.URL) 77 .withUrl(url) 78 .buildOrThrow(); 79 80 const card2 = new CardBuilder() 81 .withCuratorId(curator2.value) 82 .withType(CardTypeEnum.URL) 83 .withUrl(url) 84 .buildOrThrow(); 85 86 const card3 = new CardBuilder() 87 .withCuratorId(curator3.value) 88 .withType(CardTypeEnum.URL) 89 .withUrl(url) 90 .buildOrThrow(); 91 92 // Add cards to their respective libraries 93 card1.addToLibrary(curator1); 94 card2.addToLibrary(curator2); 95 card3.addToLibrary(curator3); 96 97 // Save cards 98 await cardRepository.save(card1); 99 await cardRepository.save(card2); 100 await cardRepository.save(card3); 101 102 // Execute the query 103 const result = await queryRepository.getLibrariesForUrl(testUrl, { 104 page: 1, 105 limit: 10, 106 sortBy: CardSortField.UPDATED_AT, 107 sortOrder: SortOrder.DESC, 108 }); 109 110 // Verify the result 111 expect(result.items).toHaveLength(3); 112 expect(result.totalCount).toBe(3); 113 expect(result.hasMore).toBe(false); 114 115 // Check that all three users are included 116 const userIds = result.items.map((lib) => lib.userId); 117 expect(userIds).toContain(curator1.value); 118 expect(userIds).toContain(curator2.value); 119 expect(userIds).toContain(curator3.value); 120 121 // Check that card IDs are correct 122 const cardIds = result.items.map((lib) => lib.card.id); 123 expect(cardIds).toContain(card1.cardId.getStringValue()); 124 expect(cardIds).toContain(card2.cardId.getStringValue()); 125 expect(cardIds).toContain(card3.cardId.getStringValue()); 126 }); 127 128 it('should return empty result when no users have cards with the specified URL', async () => { 129 const testUrl = 'https://example.com/nonexistent-article'; 130 131 const result = await queryRepository.getLibrariesForUrl(testUrl, { 132 page: 1, 133 limit: 10, 134 sortBy: CardSortField.UPDATED_AT, 135 sortOrder: SortOrder.DESC, 136 }); 137 138 expect(result.items).toHaveLength(0); 139 expect(result.totalCount).toBe(0); 140 expect(result.hasMore).toBe(false); 141 }); 142 143 it('should not return users who have different URLs', async () => { 144 const testUrl1 = 'https://example.com/article1'; 145 const testUrl2 = 'https://example.com/article2'; 146 const url1 = URL.create(testUrl1).unwrap(); 147 const url2 = URL.create(testUrl2).unwrap(); 148 149 // Create cards with different URLs 150 const card1 = new CardBuilder() 151 .withCuratorId(curator1.value) 152 .withType(CardTypeEnum.URL) 153 .withUrl(url1) 154 .buildOrThrow(); 155 156 const card2 = new CardBuilder() 157 .withCuratorId(curator2.value) 158 .withType(CardTypeEnum.URL) 159 .withUrl(url2) 160 .buildOrThrow(); 161 162 card1.addToLibrary(curator1); 163 card2.addToLibrary(curator2); 164 165 await cardRepository.save(card1); 166 await cardRepository.save(card2); 167 168 // Query for testUrl1 169 const result = await queryRepository.getLibrariesForUrl(testUrl1, { 170 page: 1, 171 limit: 10, 172 sortBy: CardSortField.UPDATED_AT, 173 sortOrder: SortOrder.DESC, 174 }); 175 176 expect(result.items).toHaveLength(1); 177 expect(result.items[0]!.userId).toBe(curator1.value); 178 expect(result.items[0]!.card.id).toBe(card1.cardId.getStringValue()); 179 }); 180 181 it('should not return NOTE cards even if they have the same URL', async () => { 182 const testUrl = 'https://example.com/article'; 183 const url = URL.create(testUrl).unwrap(); 184 185 // Create URL card 186 const urlCard = new CardBuilder() 187 .withCuratorId(curator1.value) 188 .withType(CardTypeEnum.URL) 189 .withUrl(url) 190 .buildOrThrow(); 191 192 // Create NOTE card with same URL (shouldn't happen in practice but test edge case) 193 const noteCard = new CardBuilder() 194 .withCuratorId(curator2.value) 195 .withType(CardTypeEnum.NOTE) 196 .withUrl(url) 197 .buildOrThrow(); 198 199 urlCard.addToLibrary(curator1); 200 noteCard.addToLibrary(curator2); 201 202 await cardRepository.save(urlCard); 203 await cardRepository.save(noteCard); 204 205 const result = await queryRepository.getLibrariesForUrl(testUrl, { 206 page: 1, 207 limit: 10, 208 sortBy: CardSortField.UPDATED_AT, 209 sortOrder: SortOrder.DESC, 210 }); 211 212 // Should only return the URL card, not the NOTE card 213 expect(result.items).toHaveLength(1); 214 expect(result.items[0]!.userId).toBe(curator1.value); 215 expect(result.items[0]!.card.id).toBe(urlCard.cardId.getStringValue()); 216 }); 217 218 it('should handle multiple cards from same user with same URL', async () => { 219 const testUrl = 'https://example.com/article'; 220 const url = URL.create(testUrl).unwrap(); 221 222 // Create two URL cards from same user with same URL 223 const card1 = new CardBuilder() 224 .withCuratorId(curator1.value) 225 .withType(CardTypeEnum.URL) 226 .withUrl(url) 227 .buildOrThrow(); 228 229 const card2 = new CardBuilder() 230 .withCuratorId(curator1.value) 231 .withType(CardTypeEnum.URL) 232 .withUrl(url) 233 .buildOrThrow(); 234 235 card1.addToLibrary(curator1); 236 card2.addToLibrary(curator1); 237 238 await cardRepository.save(card1); 239 await cardRepository.save(card2); 240 241 const result = await queryRepository.getLibrariesForUrl(testUrl, { 242 page: 1, 243 limit: 10, 244 sortBy: CardSortField.UPDATED_AT, 245 sortOrder: SortOrder.DESC, 246 }); 247 248 // Should return both cards 249 expect(result.items).toHaveLength(2); 250 expect(result.totalCount).toBe(2); 251 252 // Both should be from the same user 253 expect(result.items[0]!.userId).toBe(curator1.value); 254 expect(result.items[1]!.userId).toBe(curator1.value); 255 256 // But different card IDs 257 const cardIds = result.items.map((lib) => lib.card.id); 258 expect(cardIds).toContain(card1.cardId.getStringValue()); 259 expect(cardIds).toContain(card2.cardId.getStringValue()); 260 }); 261 262 it('should handle cards not in any library', async () => { 263 const testUrl = 'https://example.com/article'; 264 const url = URL.create(testUrl).unwrap(); 265 266 // Create URL card but don't add to library 267 const card = new CardBuilder() 268 .withCuratorId(curator1.value) 269 .withType(CardTypeEnum.URL) 270 .withUrl(url) 271 .buildOrThrow(); 272 273 await cardRepository.save(card); 274 275 const result = await queryRepository.getLibrariesForUrl(testUrl, { 276 page: 1, 277 limit: 10, 278 sortBy: CardSortField.UPDATED_AT, 279 sortOrder: SortOrder.DESC, 280 }); 281 282 // Should return empty since card is not in any library 283 expect(result.items).toHaveLength(0); 284 expect(result.totalCount).toBe(0); 285 }); 286 }); 287 288 describe('sorting', () => { 289 it('should sort by createdAt in descending order by default', async () => { 290 const testUrl = 'https://example.com/sort-test'; 291 const url = URL.create(testUrl).unwrap(); 292 293 // Create cards with different creation times 294 const card1 = new CardBuilder() 295 .withCuratorId(curator1.value) 296 .withType(CardTypeEnum.URL) 297 .withUrl(url) 298 .buildOrThrow(); 299 300 await new Promise((resolve) => setTimeout(resolve, 1000)); 301 const card2 = new CardBuilder() 302 .withCuratorId(curator2.value) 303 .withType(CardTypeEnum.URL) 304 .withUrl(url) 305 .buildOrThrow(); 306 307 await new Promise((resolve) => setTimeout(resolve, 1000)); 308 const card3 = new CardBuilder() 309 .withCuratorId(curator3.value) 310 .withType(CardTypeEnum.URL) 311 .withUrl(url) 312 .buildOrThrow(); 313 314 card1.addToLibrary(curator1); 315 card2.addToLibrary(curator2); 316 card3.addToLibrary(curator3); 317 318 // Save cards with slight delays to ensure different timestamps 319 await cardRepository.save(card1); 320 await new Promise((resolve) => setTimeout(resolve, 10)); 321 await cardRepository.save(card2); 322 await new Promise((resolve) => setTimeout(resolve, 10)); 323 await cardRepository.save(card3); 324 325 const result = await queryRepository.getLibrariesForUrl(testUrl, { 326 page: 1, 327 limit: 10, 328 sortBy: CardSortField.CREATED_AT, 329 sortOrder: SortOrder.DESC, 330 }); 331 332 expect(result.items).toHaveLength(3); 333 334 // Should be sorted by creation time, newest first 335 const cardIds = result.items.map((lib) => lib.card.id); 336 expect(cardIds[0]).toBe(card3.cardId.getStringValue()); // Most recent 337 expect(cardIds[1]).toBe(card2.cardId.getStringValue()); // Middle 338 expect(cardIds[2]).toBe(card1.cardId.getStringValue()); // Oldest 339 }); 340 341 it('should sort by createdAt in ascending order when specified', async () => { 342 const testUrl = 'https://example.com/sort-asc-test'; 343 const url = URL.create(testUrl).unwrap(); 344 345 // Create cards with different creation times 346 const card1 = new CardBuilder() 347 .withCuratorId(curator1.value) 348 .withType(CardTypeEnum.URL) 349 .withUrl(url) 350 .buildOrThrow(); 351 352 const card2 = new CardBuilder() 353 .withCuratorId(curator2.value) 354 .withType(CardTypeEnum.URL) 355 .withUrl(url) 356 .buildOrThrow(); 357 358 card1.addToLibrary(curator1); 359 card2.addToLibrary(curator2); 360 361 // Save cards with slight delay to ensure different timestamps 362 await cardRepository.save(card1); 363 await new Promise((resolve) => setTimeout(resolve, 10)); 364 await cardRepository.save(card2); 365 366 const result = await queryRepository.getLibrariesForUrl(testUrl, { 367 page: 1, 368 limit: 10, 369 sortBy: CardSortField.CREATED_AT, 370 sortOrder: SortOrder.ASC, 371 }); 372 373 expect(result.items).toHaveLength(2); 374 375 // Should be sorted by creation time, oldest first 376 const cardIds = result.items.map((lib) => lib.card.id); 377 expect(cardIds[0]).toBe(card1.cardId.getStringValue()); // Oldest 378 expect(cardIds[1]).toBe(card2.cardId.getStringValue()); // Newest 379 }); 380 381 it('should sort by updatedAt in descending order', async () => { 382 const testUrl = 'https://example.com/sort-updated-test'; 383 const url = URL.create(testUrl).unwrap(); 384 385 // Create cards 386 const card1 = new CardBuilder() 387 .withCuratorId(curator1.value) 388 .withType(CardTypeEnum.URL) 389 .withUrl(url) 390 .buildOrThrow(); 391 392 const card2 = new CardBuilder() 393 .withCuratorId(curator2.value) 394 .withType(CardTypeEnum.URL) 395 .withUrl(url) 396 .buildOrThrow(); 397 398 card1.addToLibrary(curator1); 399 card2.addToLibrary(curator2); 400 401 // Save cards 402 await cardRepository.save(card1); 403 await cardRepository.save(card2); 404 405 // Update card1 to have a more recent updatedAt 406 await new Promise((resolve) => setTimeout(resolve, 1000)); 407 card1.markAsPublished( 408 PublishedRecordId.create({ 409 uri: 'at://did:plc:publishedrecord1', 410 cid: 'bafyreicpublishedrecord1', 411 }), 412 ); 413 await cardRepository.save(card1); // This should update the updatedAt timestamp 414 415 const result = await queryRepository.getLibrariesForUrl(testUrl, { 416 page: 1, 417 limit: 10, 418 sortBy: CardSortField.UPDATED_AT, 419 sortOrder: SortOrder.DESC, 420 }); 421 422 expect(result.items).toHaveLength(2); 423 424 // card1 should be first since it was updated more recently 425 const cardIds = result.items.map((lib) => lib.card.id); 426 expect(cardIds[0]).toBe(card1.cardId.getStringValue()); // Most recently updated 427 expect(cardIds[1]).toBe(card2.cardId.getStringValue()); // Less recently updated 428 }); 429 430 it('should sort by libraryCount in descending order', async () => { 431 const testUrl = 'https://example.com/sort-library-count-test'; 432 const url = URL.create(testUrl).unwrap(); 433 434 // Create cards 435 const card1 = new CardBuilder() 436 .withCuratorId(curator1.value) 437 .withType(CardTypeEnum.URL) 438 .withUrl(url) 439 .buildOrThrow(); 440 441 const card2 = new CardBuilder() 442 .withCuratorId(curator2.value) 443 .withType(CardTypeEnum.URL) 444 .withUrl(url) 445 .buildOrThrow(); 446 447 const card3 = new CardBuilder() 448 .withCuratorId(curator3.value) 449 .withType(CardTypeEnum.URL) 450 .withUrl(url) 451 .buildOrThrow(); 452 453 // Add cards to libraries with different counts 454 card1.addToLibrary(curator1); 455 456 card2.addToLibrary(curator2); 457 card2.addToLibrary(curator1); // card2 has 2 library memberships 458 459 card3.addToLibrary(curator3); 460 card3.addToLibrary(curator1); // card3 has 3 library memberships 461 card3.addToLibrary(curator2); 462 463 await cardRepository.save(card1); 464 await cardRepository.save(card2); 465 await cardRepository.save(card3); 466 467 const result = await queryRepository.getLibrariesForUrl(testUrl, { 468 page: 1, 469 limit: 10, 470 sortBy: CardSortField.LIBRARY_COUNT, 471 sortOrder: SortOrder.DESC, 472 }); 473 474 // Should return all library memberships, but sorted by the card's library count 475 expect(result.items.length).toBeGreaterThan(0); 476 477 // Group by card ID to check sorting 478 const cardGroups = new Map<string, any[]>(); 479 result.items.forEach((item) => { 480 const cardId = item.card.id; 481 if (!cardGroups.has(cardId)) { 482 cardGroups.set(cardId, []); 483 } 484 cardGroups.get(cardId)!.push(item); 485 }); 486 487 // Get the first occurrence of each card to check library count ordering 488 const uniqueCards = Array.from(cardGroups.entries()).map( 489 ([cardId, items]) => ({ 490 cardId, 491 libraryCount: items[0]!.card.libraryCount, 492 }), 493 ); 494 495 // Should be sorted by library count descending 496 for (let i = 0; i < uniqueCards.length - 1; i++) { 497 expect(uniqueCards[i]!.libraryCount).toBeGreaterThanOrEqual( 498 uniqueCards[i + 1]!.libraryCount, 499 ); 500 } 501 }); 502 503 it('should sort by libraryCount in ascending order when specified', async () => { 504 const testUrl = 'https://example.com/sort-library-count-asc-test'; 505 const url = URL.create(testUrl).unwrap(); 506 507 // Create cards with different library counts 508 const card1 = new CardBuilder() 509 .withCuratorId(curator1.value) 510 .withType(CardTypeEnum.URL) 511 .withUrl(url) 512 .buildOrThrow(); 513 514 const card2 = new CardBuilder() 515 .withCuratorId(curator2.value) 516 .withType(CardTypeEnum.URL) 517 .withUrl(url) 518 .buildOrThrow(); 519 520 // card1 has 1 library membership, card2 has 2 521 card1.addToLibrary(curator1); 522 card2.addToLibrary(curator2); 523 card2.addToLibrary(curator1); 524 525 await cardRepository.save(card1); 526 await cardRepository.save(card2); 527 528 const result = await queryRepository.getLibrariesForUrl(testUrl, { 529 page: 1, 530 limit: 10, 531 sortBy: CardSortField.LIBRARY_COUNT, 532 sortOrder: SortOrder.ASC, 533 }); 534 535 expect(result.items.length).toBeGreaterThan(0); 536 537 // Group by card ID and check ascending order 538 const cardGroups = new Map<string, any[]>(); 539 result.items.forEach((item) => { 540 const cardId = item.card.id; 541 if (!cardGroups.has(cardId)) { 542 cardGroups.set(cardId, []); 543 } 544 cardGroups.get(cardId)!.push(item); 545 }); 546 547 const uniqueCards = Array.from(cardGroups.entries()).map( 548 ([cardId, items]) => ({ 549 cardId, 550 libraryCount: items[0]!.card.libraryCount, 551 }), 552 ); 553 554 // Should be sorted by library count ascending 555 for (let i = 0; i < uniqueCards.length - 1; i++) { 556 expect(uniqueCards[i]!.libraryCount).toBeLessThanOrEqual( 557 uniqueCards[i + 1]!.libraryCount, 558 ); 559 } 560 }); 561 }); 562 563 describe('pagination', () => { 564 it('should paginate results correctly', async () => { 565 const testUrl = 'https://example.com/popular-article'; 566 const url = URL.create(testUrl).unwrap(); 567 568 // Create 5 cards with the same URL from different users 569 const cards = []; 570 const curators = []; 571 for (let i = 1; i <= 5; i++) { 572 const curator = CuratorId.create(`did:plc:curator${i}`).unwrap(); 573 curators.push(curator); 574 575 const card = new CardBuilder() 576 .withCuratorId(curator.value) 577 .withType(CardTypeEnum.URL) 578 .withUrl(url) 579 .buildOrThrow(); 580 581 card.addToLibrary(curator); 582 cards.push(card); 583 await cardRepository.save(card); 584 } 585 586 // Test first page with limit 2 587 const result1 = await queryRepository.getLibrariesForUrl(testUrl, { 588 page: 1, 589 limit: 2, 590 sortBy: CardSortField.UPDATED_AT, 591 sortOrder: SortOrder.DESC, 592 }); 593 594 expect(result1.items).toHaveLength(2); 595 expect(result1.totalCount).toBe(5); 596 expect(result1.hasMore).toBe(true); 597 598 // Test second page 599 const result2 = await queryRepository.getLibrariesForUrl(testUrl, { 600 page: 2, 601 limit: 2, 602 sortBy: CardSortField.UPDATED_AT, 603 sortOrder: SortOrder.DESC, 604 }); 605 606 expect(result2.items).toHaveLength(2); 607 expect(result2.totalCount).toBe(5); 608 expect(result2.hasMore).toBe(true); 609 610 // Test last page 611 const result3 = await queryRepository.getLibrariesForUrl(testUrl, { 612 page: 3, 613 limit: 2, 614 sortBy: CardSortField.UPDATED_AT, 615 sortOrder: SortOrder.DESC, 616 }); 617 618 expect(result3.items).toHaveLength(1); 619 expect(result3.totalCount).toBe(5); 620 expect(result3.hasMore).toBe(false); 621 622 // Verify no duplicate entries across pages 623 const allUserIds = [ 624 ...result1.items.map((lib) => lib.userId), 625 ...result2.items.map((lib) => lib.userId), 626 ...result3.items.map((lib) => lib.userId), 627 ]; 628 const uniqueUserIds = [...new Set(allUserIds)]; 629 expect(uniqueUserIds).toHaveLength(5); 630 }); 631 632 it('should handle empty pages correctly', async () => { 633 const testUrl = 'https://example.com/empty-test'; 634 635 const result = await queryRepository.getLibrariesForUrl(testUrl, { 636 page: 2, 637 limit: 10, 638 sortBy: CardSortField.UPDATED_AT, 639 sortOrder: SortOrder.DESC, 640 }); 641 642 expect(result.items).toHaveLength(0); 643 expect(result.totalCount).toBe(0); 644 expect(result.hasMore).toBe(false); 645 }); 646 647 it('should handle large page numbers gracefully', async () => { 648 const testUrl = 'https://example.com/single-card'; 649 const url = URL.create(testUrl).unwrap(); 650 651 // Create single card 652 const card = new CardBuilder() 653 .withCuratorId(curator1.value) 654 .withType(CardTypeEnum.URL) 655 .withUrl(url) 656 .buildOrThrow(); 657 658 card.addToLibrary(curator1); 659 await cardRepository.save(card); 660 661 // Request page 10 when there's only 1 item 662 const result = await queryRepository.getLibrariesForUrl(testUrl, { 663 page: 10, 664 limit: 10, 665 sortBy: CardSortField.UPDATED_AT, 666 sortOrder: SortOrder.DESC, 667 }); 668 669 expect(result.items).toHaveLength(0); 670 expect(result.totalCount).toBe(1); 671 expect(result.hasMore).toBe(false); 672 }); 673 }); 674});