diff --git a/src/modules/cards/application/useCases/queries/GetCollectionsForUrlUseCase.ts b/src/modules/cards/application/useCases/queries/GetCollectionsForUrlUseCase.ts index 7923b5f6..3b845ee3 100644 --- a/src/modules/cards/application/useCases/queries/GetCollectionsForUrlUseCase.ts +++ b/src/modules/cards/application/useCases/queries/GetCollectionsForUrlUseCase.ts @@ -5,6 +5,8 @@ import { URL } from '../../../domain/value-objects/URL'; export interface GetCollectionsForUrlQuery { url: string; + page?: number; + limit?: number; } export interface CollectionForUrlDTO { @@ -17,6 +19,13 @@ export interface CollectionForUrlDTO { export interface GetCollectionsForUrlResult { collections: CollectionForUrlDTO[]; + pagination: { + currentPage: number; + totalPages: number; + totalCount: number; + hasMore: boolean; + limit: number; + }; } export class ValidationError extends Error { @@ -43,13 +52,29 @@ export class GetCollectionsForUrlUseCase ); } + // Set defaults + const page = query.page || 1; + const limit = Math.min(query.limit || 20, 100); // Cap at 100 + try { // Execute query to get collections containing cards with this URL - const collections = - await this.collectionQueryRepo.getCollectionsWithUrl(query.url); + const result = await this.collectionQueryRepo.getCollectionsWithUrl( + query.url, + { + page, + limit, + }, + ); return ok({ - collections, + collections: result.items, + pagination: { + currentPage: page, + totalPages: Math.ceil(result.totalCount / limit), + totalCount: result.totalCount, + hasMore: result.hasMore, + limit, + }, }); } catch (error) { return err( diff --git a/src/modules/cards/domain/ICollectionQueryRepository.ts b/src/modules/cards/domain/ICollectionQueryRepository.ts index 6ae492d8..2b86cfec 100644 --- a/src/modules/cards/domain/ICollectionQueryRepository.ts +++ b/src/modules/cards/domain/ICollectionQueryRepository.ts @@ -50,6 +50,11 @@ export interface CollectionForUrlDTO { authorId: string; } +export interface CollectionForUrlQueryOptions { + page: number; + limit: number; +} + export interface ICollectionQueryRepository { findByCreator( curatorId: string, @@ -61,5 +66,8 @@ export interface ICollectionQueryRepository { curatorId: string, ): Promise; - getCollectionsWithUrl(url: string): Promise; + getCollectionsWithUrl( + url: string, + options: CollectionForUrlQueryOptions, + ): Promise>; } diff --git a/src/modules/cards/infrastructure/repositories/DrizzleCollectionQueryRepository.ts b/src/modules/cards/infrastructure/repositories/DrizzleCollectionQueryRepository.ts index 31e74eed..7d54acd0 100644 --- a/src/modules/cards/infrastructure/repositories/DrizzleCollectionQueryRepository.ts +++ b/src/modules/cards/infrastructure/repositories/DrizzleCollectionQueryRepository.ts @@ -9,6 +9,7 @@ import { SortOrder, CollectionContainingCardDTO, CollectionForUrlDTO, + CollectionForUrlQueryOptions, } from '../../domain/ICollectionQueryRepository'; import { collections, collectionCards } from './schema/collection.sql'; import { publishedRecords } from './schema/publishedRecord.sql'; @@ -154,8 +155,14 @@ export class DrizzleCollectionQueryRepository } } - async getCollectionsWithUrl(url: string): Promise { + async getCollectionsWithUrl( + url: string, + options: CollectionForUrlQueryOptions, + ): Promise> { try { + const { page, limit } = options; + const offset = (page - 1) * limit; + // Find all URL cards with this URL const urlCardsQuery = this.db .select({ @@ -167,12 +174,16 @@ export class DrizzleCollectionQueryRepository const urlCardsResult = await urlCardsQuery; if (urlCardsResult.length === 0) { - return []; + return { + items: [], + totalCount: 0, + hasMore: false, + }; } const cardIds = urlCardsResult.map((card) => card.id); - // Find all collections that contain any of these cards + // Find all collections that contain any of these cards with pagination const collectionsQuery = this.db .selectDistinct({ id: collections.id, @@ -191,17 +202,41 @@ export class DrizzleCollectionQueryRepository eq(collections.id, collectionCards.collectionId), ) .where(inArray(collectionCards.cardId, cardIds)) - .orderBy(asc(collections.name)); + .orderBy(asc(collections.name)) + .limit(limit) + .offset(offset); const collectionsResult = await collectionsQuery; - return collectionsResult.map((result) => ({ + // Get total count of distinct collections + const totalCountQuery = this.db + .selectDistinct({ + id: collections.id, + }) + .from(collections) + .innerJoin( + collectionCards, + eq(collections.id, collectionCards.collectionId), + ) + .where(inArray(collectionCards.cardId, cardIds)); + + const totalCountResult = await totalCountQuery; + const totalCount = totalCountResult.length; + const hasMore = offset + collectionsResult.length < totalCount; + + const items = collectionsResult.map((result) => ({ id: result.id, uri: result.uri || undefined, name: result.name, description: result.description || undefined, authorId: result.authorId, })); + + return { + items, + totalCount, + hasMore, + }; } catch (error) { console.error('Error in getCollectionsWithUrl:', error); throw error; diff --git a/src/modules/cards/tests/application/GetCollectionsForUrlUseCase.test.ts b/src/modules/cards/tests/application/GetCollectionsForUrlUseCase.test.ts index a791acfd..154bca81 100644 --- a/src/modules/cards/tests/application/GetCollectionsForUrlUseCase.test.ts +++ b/src/modules/cards/tests/application/GetCollectionsForUrlUseCase.test.ts @@ -145,6 +145,7 @@ describe('GetCollectionsForUrlUseCase', () => { const response = result.unwrap(); expect(response.collections).toHaveLength(3); + expect(response.pagination.totalCount).toBe(3); // Check that all three collections are included const collectionIds = response.collections.map((c) => c.id); @@ -197,6 +198,7 @@ describe('GetCollectionsForUrlUseCase', () => { const response = result.unwrap(); expect(response.collections).toHaveLength(0); + expect(response.pagination.totalCount).toBe(0); }); it('should not return collections that contain cards with different URLs', async () => { @@ -385,6 +387,126 @@ describe('GetCollectionsForUrlUseCase', () => { }); }); + describe('Pagination', () => { + it('should paginate results correctly', async () => { + const testUrl = 'https://example.com/popular-article'; + const url = URL.create(testUrl).unwrap(); + + // Create 5 cards with the same URL from different users + const cards = []; + const curators = []; + const collections = []; + + for (let i = 1; i <= 5; i++) { + const curator = CuratorId.create(`did:plc:curator${i}`).unwrap(); + curators.push(curator); + + const card = new CardBuilder() + .withCuratorId(curator.value) + .withType(CardTypeEnum.URL) + .withUrl(url) + .build(); + + if (card instanceof Error) { + throw new Error(`Failed to create card ${i}`); + } + + card.addToLibrary(curator); + cards.push(card); + await cardRepository.save(card); + + // Create collection for each user + const collection = new CollectionBuilder() + .withAuthorId(curator.value) + .withName(`Collection ${i}`) + .build(); + + if (collection instanceof Error) { + throw new Error(`Failed to create collection ${i}`); + } + + collection.addCard(card.cardId, curator); + collections.push(collection); + await collectionRepository.save(collection); + } + + // Test first page with limit 2 + const query1 = { + url: testUrl, + page: 1, + limit: 2, + }; + + const result1 = await useCase.execute(query1); + expect(result1.isOk()).toBe(true); + const response1 = result1.unwrap(); + + expect(response1.collections).toHaveLength(2); + expect(response1.pagination.currentPage).toBe(1); + expect(response1.pagination.totalCount).toBe(5); + expect(response1.pagination.totalPages).toBe(3); + expect(response1.pagination.hasMore).toBe(true); + + // Test second page + const query2 = { + url: testUrl, + page: 2, + limit: 2, + }; + + const result2 = await useCase.execute(query2); + expect(result2.isOk()).toBe(true); + const response2 = result2.unwrap(); + + expect(response2.collections).toHaveLength(2); + expect(response2.pagination.currentPage).toBe(2); + expect(response2.pagination.hasMore).toBe(true); + + // Test last page + const query3 = { + url: testUrl, + page: 3, + limit: 2, + }; + + const result3 = await useCase.execute(query3); + expect(result3.isOk()).toBe(true); + const response3 = result3.unwrap(); + + expect(response3.collections).toHaveLength(1); + expect(response3.pagination.currentPage).toBe(3); + expect(response3.pagination.hasMore).toBe(false); + }); + + it('should respect limit cap of 100', async () => { + const query = { + url: 'https://example.com/test', + limit: 200, // Should be capped at 100 + }; + + const result = await useCase.execute(query); + expect(result.isOk()).toBe(true); + const response = result.unwrap(); + + expect(response.pagination.limit).toBe(100); + }); + + it('should use default pagination values', async () => { + const testUrl = 'https://example.com/test-article'; + + const query = { + url: testUrl, + }; + + const result = await useCase.execute(query); + expect(result.isOk()).toBe(true); + const response = result.unwrap(); + + expect(response.pagination.currentPage).toBe(1); + expect(response.pagination.limit).toBe(20); + }); + }); + describe('Validation', () => { it('should fail with invalid URL', async () => { const query = { diff --git a/src/modules/cards/tests/infrastructure/DrizzleCollectionQueryRepository.getCollectionsWithUrl.integration.test.ts b/src/modules/cards/tests/infrastructure/DrizzleCollectionQueryRepository.getCollectionsWithUrl.integration.test.ts index 9d9e9671..74ce9453 100644 --- a/src/modules/cards/tests/infrastructure/DrizzleCollectionQueryRepository.getCollectionsWithUrl.integration.test.ts +++ b/src/modules/cards/tests/infrastructure/DrizzleCollectionQueryRepository.getCollectionsWithUrl.integration.test.ts @@ -153,13 +153,18 @@ describe('DrizzleCollectionQueryRepository - getCollectionsWithUrl', () => { await collectionRepository.save(collection3); // Execute the query - const result = await queryRepository.getCollectionsWithUrl(testUrl); + const result = await queryRepository.getCollectionsWithUrl(testUrl, { + page: 1, + limit: 10, + }); // Verify the result - expect(result).toHaveLength(3); + expect(result.items).toHaveLength(3); + expect(result.totalCount).toBe(3); + expect(result.hasMore).toBe(false); // Check that all three collections are included - const collectionIds = result.map((c) => c.id); + const collectionIds = result.items.map((c) => c.id); expect(collectionIds).toContain( collection1.collectionId.getStringValue(), ); @@ -171,7 +176,7 @@ describe('DrizzleCollectionQueryRepository - getCollectionsWithUrl', () => { ); // Verify collection details - const techArticles = result.find((c) => c.name === 'Tech Articles'); + const techArticles = result.items.find((c) => c.name === 'Tech Articles'); expect(techArticles).toBeDefined(); expect(techArticles?.description).toBe('My tech articles'); expect(techArticles?.authorId).toBe(curator1.value); @@ -179,12 +184,12 @@ describe('DrizzleCollectionQueryRepository - getCollectionsWithUrl', () => { 'at://did:plc:curator1/network.cosmik.collection/collection1', ); - const readingList = result.find((c) => c.name === 'Reading List'); + const readingList = result.items.find((c) => c.name === 'Reading List'); expect(readingList).toBeDefined(); expect(readingList?.description).toBe('Articles to read'); expect(readingList?.authorId).toBe(curator2.value); - const favorites = result.find((c) => c.name === 'Favorites'); + const favorites = result.items.find((c) => c.name === 'Favorites'); expect(favorites).toBeDefined(); expect(favorites?.description).toBeUndefined(); expect(favorites?.authorId).toBe(curator3.value); @@ -193,9 +198,14 @@ describe('DrizzleCollectionQueryRepository - getCollectionsWithUrl', () => { it('should return empty array when no collections contain cards with the specified URL', async () => { const testUrl = 'https://example.com/nonexistent-article'; - const result = await queryRepository.getCollectionsWithUrl(testUrl); + const result = await queryRepository.getCollectionsWithUrl(testUrl, { + page: 1, + limit: 10, + }); - expect(result).toHaveLength(0); + expect(result.items).toHaveLength(0); + expect(result.totalCount).toBe(0); + expect(result.hasMore).toBe(false); }); it('should not return collections that contain cards with different URLs', async () => { @@ -241,11 +251,14 @@ describe('DrizzleCollectionQueryRepository - getCollectionsWithUrl', () => { await collectionRepository.save(collection2); // Query for testUrl1 - const result = await queryRepository.getCollectionsWithUrl(testUrl1); + const result = await queryRepository.getCollectionsWithUrl(testUrl1, { + page: 1, + limit: 10, + }); - expect(result).toHaveLength(1); - expect(result[0]!.name).toBe('Collection 1'); - expect(result[0]!.authorId).toBe(curator1.value); + expect(result.items).toHaveLength(1); + expect(result.items[0]!.name).toBe('Collection 1'); + expect(result.items[0]!.authorId).toBe(curator1.value); }); it('should return multiple collections from the same user if they contain the URL', async () => { @@ -288,17 +301,20 @@ describe('DrizzleCollectionQueryRepository - getCollectionsWithUrl', () => { await collectionRepository.save(collection3); // Execute the query - const result = await queryRepository.getCollectionsWithUrl(testUrl); + const result = await queryRepository.getCollectionsWithUrl(testUrl, { + page: 1, + limit: 10, + }); - expect(result).toHaveLength(3); + expect(result.items).toHaveLength(3); - const collectionNames = result.map((c) => c.name); + const collectionNames = result.items.map((c) => c.name); expect(collectionNames).toContain('Tech'); expect(collectionNames).toContain('Favorites'); expect(collectionNames).toContain('To Read'); // All should have the same author - result.forEach((collection) => { + result.items.forEach((collection) => { expect(collection.authorId).toBe(curator1.value); }); }); @@ -327,11 +343,14 @@ describe('DrizzleCollectionQueryRepository - getCollectionsWithUrl', () => { await collectionRepository.save(collection); // Execute the query - const result = await queryRepository.getCollectionsWithUrl(testUrl); + const result = await queryRepository.getCollectionsWithUrl(testUrl, { + page: 1, + limit: 10, + }); - expect(result).toHaveLength(1); - expect(result[0]!.name).toBe('Unpublished Collection'); - expect(result[0]!.uri).toBeUndefined(); + expect(result.items).toHaveLength(1); + expect(result.items[0]!.name).toBe('Unpublished Collection'); + expect(result.items[0]!.uri).toBeUndefined(); }); it('should handle multiple cards with same URL from different users in same collection', async () => { @@ -369,12 +388,15 @@ describe('DrizzleCollectionQueryRepository - getCollectionsWithUrl', () => { await collectionRepository.save(collection); // Execute the query - const result = await queryRepository.getCollectionsWithUrl(testUrl); + const result = await queryRepository.getCollectionsWithUrl(testUrl, { + page: 1, + limit: 10, + }); // Should return the collection only once, even though it has multiple cards with the URL - expect(result).toHaveLength(1); - expect(result[0]!.name).toBe('Shared Collection'); - expect(result[0]!.authorId).toBe(curator1.value); + expect(result.items).toHaveLength(1); + expect(result.items[0]!.name).toBe('Shared Collection'); + expect(result.items[0]!.authorId).toBe(curator1.value); }); it('should not return collections containing NOTE cards with the URL', async () => { @@ -418,12 +440,15 @@ describe('DrizzleCollectionQueryRepository - getCollectionsWithUrl', () => { await collectionRepository.save(collection1); await collectionRepository.save(collection2); - const result = await queryRepository.getCollectionsWithUrl(testUrl); + const result = await queryRepository.getCollectionsWithUrl(testUrl, { + page: 1, + limit: 10, + }); // Should only return the collection with the URL card, not the NOTE card - expect(result).toHaveLength(1); - expect(result[0]!.name).toBe('URL Collection'); - expect(result[0]!.authorId).toBe(curator1.value); + expect(result.items).toHaveLength(1); + expect(result.items[0]!.name).toBe('URL Collection'); + expect(result.items[0]!.authorId).toBe(curator1.value); }); it('should handle cards not in any collection', async () => { @@ -440,10 +465,13 @@ describe('DrizzleCollectionQueryRepository - getCollectionsWithUrl', () => { card.addToLibrary(curator1); await cardRepository.save(card); - const result = await queryRepository.getCollectionsWithUrl(testUrl); + const result = await queryRepository.getCollectionsWithUrl(testUrl, { + page: 1, + limit: 10, + }); // Should return empty since card is not in any collection - expect(result).toHaveLength(0); + expect(result.items).toHaveLength(0); }); it('should return collections sorted alphabetically by name', async () => { @@ -501,12 +529,137 @@ describe('DrizzleCollectionQueryRepository - getCollectionsWithUrl', () => { await collectionRepository.save(collectionA); await collectionRepository.save(collectionM); - const result = await queryRepository.getCollectionsWithUrl(testUrl); + const result = await queryRepository.getCollectionsWithUrl(testUrl, { + page: 1, + limit: 10, + }); + + expect(result.items).toHaveLength(3); + expect(result.items[0]!.name).toBe('Apple Collection'); + expect(result.items[1]!.name).toBe('Mango Collection'); + expect(result.items[2]!.name).toBe('Zebra Collection'); + }); + }); + + describe('Pagination', () => { + it('should paginate results correctly', async () => { + const testUrl = 'https://example.com/popular-article'; + const url = URL.create(testUrl).unwrap(); + + // Create 5 cards with the same URL from different users + const cards = []; + const curators = []; + const collections = []; + + for (let i = 1; i <= 5; i++) { + const curator = CuratorId.create(`did:plc:curator${i}`).unwrap(); + curators.push(curator); + + const card = new CardBuilder() + .withCuratorId(curator.value) + .withType(CardTypeEnum.URL) + .withUrl(url) + .buildOrThrow(); + + card.addToLibrary(curator); + cards.push(card); + await cardRepository.save(card); + + // Create collection for each user + const collection = new CollectionBuilder() + .withAuthorId(curator.value) + .withName(`Collection ${i}`) + .buildOrThrow(); + + collection.addCard(card.cardId, curator); + collections.push(collection); + await collectionRepository.save(collection); + } + + // Test first page with limit 2 + const result1 = await queryRepository.getCollectionsWithUrl(testUrl, { + page: 1, + limit: 2, + }); + + expect(result1.items).toHaveLength(2); + expect(result1.totalCount).toBe(5); + expect(result1.hasMore).toBe(true); + + // Test second page + const result2 = await queryRepository.getCollectionsWithUrl(testUrl, { + page: 2, + limit: 2, + }); + + expect(result2.items).toHaveLength(2); + expect(result2.totalCount).toBe(5); + expect(result2.hasMore).toBe(true); + + // Test last page + const result3 = await queryRepository.getCollectionsWithUrl(testUrl, { + page: 3, + limit: 2, + }); + + expect(result3.items).toHaveLength(1); + expect(result3.totalCount).toBe(5); + expect(result3.hasMore).toBe(false); + + // Verify no duplicate entries across pages + const allCollectionIds = [ + ...result1.items.map((c) => c.id), + ...result2.items.map((c) => c.id), + ...result3.items.map((c) => c.i), + ]; + const uniqueCollectionIds = [...new Set(allCollectionIds)]; + expect(uniqueCollectionIds).toHaveLength(5); + }); + + it('should handle empty pages correctly', async () => { + const testUrl = 'https://example.com/empty-test'; + + const result = await queryRepository.getCollectionsWithUrl(testUrl, { + page: 2, + limit: 10, + }); + + expect(result.items).toHaveLength(0); + expect(result.totalCount).toBe(0); + expect(result.hasMore).toBe(false); + }); + + it('should handle large page numbers gracefully', async () => { + const testUrl = 'https://example.com/single-collection'; + const url = URL.create(testUrl).unwrap(); + + // Create single card and collection + const card = new CardBuilder() + .withCuratorId(curator1.value) + .withType(CardTypeEnum.URL) + .withUrl(url) + .buildOrThrow(); + + card.addToLibrary(curator1); + await cardRepository.save(card); + + const collection = new CollectionBuilder() + .withAuthorId(curator1.value) + .withName('Single Collection') + .buildOrThrow(); + + collection.addCard(card.cardId, curator1); + await collectionRepository.save(collection); + + // Request page 10 when there's only 1 item + const result = await queryRepository.getCollectionsWithUrl(testUrl, { + page: 10, + limit: 10, + }); - expect(result).toHaveLength(3); - expect(result[0]!.name).toBe('Apple Collection'); - expect(result[1]!.name).toBe('Mango Collection'); - expect(result[2]!.name).toBe('Zebra Collection'); + expect(result.items).toHaveLength(0); + expect(result.totalCount).toBe(1); + expect(result.hasMore).toBe(false); }); }); }); diff --git a/src/modules/cards/tests/utils/InMemoryCollectionQueryRepository.ts b/src/modules/cards/tests/utils/InMemoryCollectionQueryRepository.ts index e1ac3c94..2cb70526 100644 --- a/src/modules/cards/tests/utils/InMemoryCollectionQueryRepository.ts +++ b/src/modules/cards/tests/utils/InMemoryCollectionQueryRepository.ts @@ -7,6 +7,7 @@ import { PaginatedQueryResult, CollectionSortField, SortOrder, + CollectionForUrlQueryOptions, } from '../../domain/ICollectionQueryRepository'; import { Collection } from '../../domain/Collection'; import { InMemoryCollectionRepository } from './InMemoryCollectionRepository'; @@ -152,7 +153,10 @@ export class InMemoryCollectionQueryRepository } } - async getCollectionsWithUrl(url: string): Promise { + async getCollectionsWithUrl( + url: string, + options: CollectionForUrlQueryOptions, + ): Promise> { try { if (!this.cardRepository) { throw new Error( @@ -176,7 +180,18 @@ export class InMemoryCollectionQueryRepository ), ); - const result: CollectionForUrlDTO[] = collectionsWithUrl.map( + // Sort by name (alphabetically) + const sortedCollections = [...collectionsWithUrl].sort((a, b) => + a.name.value.localeCompare(b.name.value), + ); + + // Apply pagination + const { page, limit } = options; + const startIndex = (page - 1) * limit; + const endIndex = startIndex + limit; + const paginatedCollections = sortedCollections.slice(startIndex, endIndex); + + const items: CollectionForUrlDTO[] = paginatedCollections.map( (collection) => { const collectionPublishedRecordId = collection.publishedRecordId; return { @@ -189,7 +204,11 @@ export class InMemoryCollectionQueryRepository }, ); - return result; + return { + items, + totalCount: sortedCollections.length, + hasMore: endIndex < sortedCollections.length, + }; } catch (error) { throw new Error( `Failed to get collections with URL: ${error instanceof Error ? error.message : String(error)}`,