A social knowledge tool for researchers built on ATProto

feat: implement sorting for libraries by URL with comprehensive test coverage

Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) <aider@aider.chat>

+264 -1
+5 -1
src/modules/cards/infrastructure/repositories/query-services/UrlCardQueryService.ts
··· 359 options: CardQueryOptions, 360 ): Promise<PaginatedQueryResult<LibraryForUrlDTO>> { 361 try { 362 - const { page, limit } = options; 363 const offset = (page - 1) * limit; 364 365 // Get all URL cards with this URL and their library memberships 366 const librariesQuery = this.db ··· 376 .from(libraryMemberships) 377 .innerJoin(cards, eq(libraryMemberships.cardId, cards.id)) 378 .where(and(eq(cards.url, url), eq(cards.type, CardTypeEnum.URL))) 379 .limit(limit) 380 .offset(offset); 381
··· 359 options: CardQueryOptions, 360 ): Promise<PaginatedQueryResult<LibraryForUrlDTO>> { 361 try { 362 + const { page, limit, sortBy, sortOrder } = options; 363 const offset = (page - 1) * limit; 364 + 365 + // Build the sort order 366 + const orderDirection = sortOrder === SortOrder.ASC ? asc : desc; 367 368 // Get all URL cards with this URL and their library memberships 369 const librariesQuery = this.db ··· 379 .from(libraryMemberships) 380 .innerJoin(cards, eq(libraryMemberships.cardId, cards.id)) 381 .where(and(eq(cards.url, url), eq(cards.type, CardTypeEnum.URL))) 382 + .orderBy(orderDirection(this.getSortColumn(sortBy))) 383 .limit(limit) 384 .offset(offset); 385
+259
src/modules/cards/tests/infrastructure/DrizzleCardQueryRepository.getLibrariesForUrl.integration.test.ts
··· 284 }); 285 }); 286 287 describe('pagination', () => { 288 it('should paginate results correctly', async () => { 289 const testUrl = 'https://example.com/popular-article';
··· 284 }); 285 }); 286 287 + describe('sorting', () => { 288 + it('should sort by createdAt in descending order by default', async () => { 289 + const testUrl = 'https://example.com/sort-test'; 290 + const url = URL.create(testUrl).unwrap(); 291 + 292 + // Create cards with different creation times 293 + const card1 = new CardBuilder() 294 + .withCuratorId(curator1.value) 295 + .withType(CardTypeEnum.URL) 296 + .withUrl(url) 297 + .buildOrThrow(); 298 + 299 + const card2 = new CardBuilder() 300 + .withCuratorId(curator2.value) 301 + .withType(CardTypeEnum.URL) 302 + .withUrl(url) 303 + .buildOrThrow(); 304 + 305 + const card3 = new CardBuilder() 306 + .withCuratorId(curator3.value) 307 + .withType(CardTypeEnum.URL) 308 + .withUrl(url) 309 + .buildOrThrow(); 310 + 311 + card1.addToLibrary(curator1); 312 + card2.addToLibrary(curator2); 313 + card3.addToLibrary(curator3); 314 + 315 + // Save cards with slight delays to ensure different timestamps 316 + await cardRepository.save(card1); 317 + await new Promise(resolve => setTimeout(resolve, 10)); 318 + await cardRepository.save(card2); 319 + await new Promise(resolve => setTimeout(resolve, 10)); 320 + await cardRepository.save(card3); 321 + 322 + const result = await queryRepository.getLibrariesForUrl(testUrl, { 323 + page: 1, 324 + limit: 10, 325 + sortBy: CardSortField.CREATED_AT, 326 + sortOrder: SortOrder.DESC, 327 + }); 328 + 329 + expect(result.items).toHaveLength(3); 330 + 331 + // Should be sorted by creation time, newest first 332 + const cardIds = result.items.map(lib => lib.card.id); 333 + expect(cardIds[0]).toBe(card3.cardId.getStringValue()); // Most recent 334 + expect(cardIds[1]).toBe(card2.cardId.getStringValue()); // Middle 335 + expect(cardIds[2]).toBe(card1.cardId.getStringValue()); // Oldest 336 + }); 337 + 338 + it('should sort by createdAt in ascending order when specified', async () => { 339 + const testUrl = 'https://example.com/sort-asc-test'; 340 + const url = URL.create(testUrl).unwrap(); 341 + 342 + // Create cards with different creation times 343 + const card1 = new CardBuilder() 344 + .withCuratorId(curator1.value) 345 + .withType(CardTypeEnum.URL) 346 + .withUrl(url) 347 + .buildOrThrow(); 348 + 349 + const card2 = new CardBuilder() 350 + .withCuratorId(curator2.value) 351 + .withType(CardTypeEnum.URL) 352 + .withUrl(url) 353 + .buildOrThrow(); 354 + 355 + card1.addToLibrary(curator1); 356 + card2.addToLibrary(curator2); 357 + 358 + // Save cards with slight delay to ensure different timestamps 359 + await cardRepository.save(card1); 360 + await new Promise(resolve => setTimeout(resolve, 10)); 361 + await cardRepository.save(card2); 362 + 363 + const result = await queryRepository.getLibrariesForUrl(testUrl, { 364 + page: 1, 365 + limit: 10, 366 + sortBy: CardSortField.CREATED_AT, 367 + sortOrder: SortOrder.ASC, 368 + }); 369 + 370 + expect(result.items).toHaveLength(2); 371 + 372 + // Should be sorted by creation time, oldest first 373 + const cardIds = result.items.map(lib => lib.card.id); 374 + expect(cardIds[0]).toBe(card1.cardId.getStringValue()); // Oldest 375 + expect(cardIds[1]).toBe(card2.cardId.getStringValue()); // Newest 376 + }); 377 + 378 + it('should sort by updatedAt in descending order', async () => { 379 + const testUrl = 'https://example.com/sort-updated-test'; 380 + const url = URL.create(testUrl).unwrap(); 381 + 382 + // Create cards 383 + const card1 = new CardBuilder() 384 + .withCuratorId(curator1.value) 385 + .withType(CardTypeEnum.URL) 386 + .withUrl(url) 387 + .buildOrThrow(); 388 + 389 + const card2 = new CardBuilder() 390 + .withCuratorId(curator2.value) 391 + .withType(CardTypeEnum.URL) 392 + .withUrl(url) 393 + .buildOrThrow(); 394 + 395 + card1.addToLibrary(curator1); 396 + card2.addToLibrary(curator2); 397 + 398 + // Save cards 399 + await cardRepository.save(card1); 400 + await cardRepository.save(card2); 401 + 402 + // Update card1 to have a more recent updatedAt 403 + await new Promise(resolve => setTimeout(resolve, 10)); 404 + await cardRepository.save(card1); // This should update the updatedAt timestamp 405 + 406 + const result = await queryRepository.getLibrariesForUrl(testUrl, { 407 + page: 1, 408 + limit: 10, 409 + sortBy: CardSortField.UPDATED_AT, 410 + sortOrder: SortOrder.DESC, 411 + }); 412 + 413 + expect(result.items).toHaveLength(2); 414 + 415 + // card1 should be first since it was updated more recently 416 + const cardIds = result.items.map(lib => lib.card.id); 417 + expect(cardIds[0]).toBe(card1.cardId.getStringValue()); // Most recently updated 418 + expect(cardIds[1]).toBe(card2.cardId.getStringValue()); // Less recently updated 419 + }); 420 + 421 + it('should sort by libraryCount in descending order', async () => { 422 + const testUrl = 'https://example.com/sort-library-count-test'; 423 + const url = URL.create(testUrl).unwrap(); 424 + 425 + // Create cards 426 + const card1 = new CardBuilder() 427 + .withCuratorId(curator1.value) 428 + .withType(CardTypeEnum.URL) 429 + .withUrl(url) 430 + .buildOrThrow(); 431 + 432 + const card2 = new CardBuilder() 433 + .withCuratorId(curator2.value) 434 + .withType(CardTypeEnum.URL) 435 + .withUrl(url) 436 + .buildOrThrow(); 437 + 438 + const card3 = new CardBuilder() 439 + .withCuratorId(curator3.value) 440 + .withType(CardTypeEnum.URL) 441 + .withUrl(url) 442 + .buildOrThrow(); 443 + 444 + // Add cards to libraries with different counts 445 + card1.addToLibrary(curator1); 446 + 447 + card2.addToLibrary(curator2); 448 + card2.addToLibrary(curator1); // card2 has 2 library memberships 449 + 450 + card3.addToLibrary(curator3); 451 + card3.addToLibrary(curator1); // card3 has 3 library memberships 452 + card3.addToLibrary(curator2); 453 + 454 + await cardRepository.save(card1); 455 + await cardRepository.save(card2); 456 + await cardRepository.save(card3); 457 + 458 + const result = await queryRepository.getLibrariesForUrl(testUrl, { 459 + page: 1, 460 + limit: 10, 461 + sortBy: CardSortField.LIBRARY_COUNT, 462 + sortOrder: SortOrder.DESC, 463 + }); 464 + 465 + // Should return all library memberships, but sorted by the card's library count 466 + expect(result.items.length).toBeGreaterThan(0); 467 + 468 + // Group by card ID to check sorting 469 + const cardGroups = new Map<string, any[]>(); 470 + result.items.forEach(item => { 471 + const cardId = item.card.id; 472 + if (!cardGroups.has(cardId)) { 473 + cardGroups.set(cardId, []); 474 + } 475 + cardGroups.get(cardId)!.push(item); 476 + }); 477 + 478 + // Get the first occurrence of each card to check library count ordering 479 + const uniqueCards = Array.from(cardGroups.entries()).map(([cardId, items]) => ({ 480 + cardId, 481 + libraryCount: items[0]!.card.libraryCount 482 + })); 483 + 484 + // Should be sorted by library count descending 485 + for (let i = 0; i < uniqueCards.length - 1; i++) { 486 + expect(uniqueCards[i]!.libraryCount).toBeGreaterThanOrEqual(uniqueCards[i + 1]!.libraryCount); 487 + } 488 + }); 489 + 490 + it('should sort by libraryCount in ascending order when specified', async () => { 491 + const testUrl = 'https://example.com/sort-library-count-asc-test'; 492 + const url = URL.create(testUrl).unwrap(); 493 + 494 + // Create cards with different library counts 495 + const card1 = new CardBuilder() 496 + .withCuratorId(curator1.value) 497 + .withType(CardTypeEnum.URL) 498 + .withUrl(url) 499 + .buildOrThrow(); 500 + 501 + const card2 = new CardBuilder() 502 + .withCuratorId(curator2.value) 503 + .withType(CardTypeEnum.URL) 504 + .withUrl(url) 505 + .buildOrThrow(); 506 + 507 + // card1 has 1 library membership, card2 has 2 508 + card1.addToLibrary(curator1); 509 + card2.addToLibrary(curator2); 510 + card2.addToLibrary(curator1); 511 + 512 + await cardRepository.save(card1); 513 + await cardRepository.save(card2); 514 + 515 + const result = await queryRepository.getLibrariesForUrl(testUrl, { 516 + page: 1, 517 + limit: 10, 518 + sortBy: CardSortField.LIBRARY_COUNT, 519 + sortOrder: SortOrder.ASC, 520 + }); 521 + 522 + expect(result.items.length).toBeGreaterThan(0); 523 + 524 + // Group by card ID and check ascending order 525 + const cardGroups = new Map<string, any[]>(); 526 + result.items.forEach(item => { 527 + const cardId = item.card.id; 528 + if (!cardGroups.has(cardId)) { 529 + cardGroups.set(cardId, []); 530 + } 531 + cardGroups.get(cardId)!.push(item); 532 + }); 533 + 534 + const uniqueCards = Array.from(cardGroups.entries()).map(([cardId, items]) => ({ 535 + cardId, 536 + libraryCount: items[0]!.card.libraryCount 537 + })); 538 + 539 + // Should be sorted by library count ascending 540 + for (let i = 0; i < uniqueCards.length - 1; i++) { 541 + expect(uniqueCards[i]!.libraryCount).toBeLessThanOrEqual(uniqueCards[i + 1]!.libraryCount); 542 + } 543 + }); 544 + }); 545 + 546 describe('pagination', () => { 547 it('should paginate results correctly', async () => { 548 const testUrl = 'https://example.com/popular-article';