import { PostgreSqlContainer, StartedPostgreSqlContainer, } from '@testcontainers/postgresql'; import postgres from 'postgres'; import { drizzle, PostgresJsDatabase } from 'drizzle-orm/postgres-js'; import { DrizzleCollectionQueryRepository } from '../../infrastructure/repositories/DrizzleCollectionQueryRepository'; import { DrizzleCardRepository } from '../../infrastructure/repositories/DrizzleCardRepository'; import { DrizzleCollectionRepository } from '../../infrastructure/repositories/DrizzleCollectionRepository'; import { CuratorId } from '../../domain/value-objects/CuratorId'; import { cards } from '../../infrastructure/repositories/schema/card.sql'; import { collections, collectionCards, } from '../../infrastructure/repositories/schema/collection.sql'; import { libraryMemberships } from '../../infrastructure/repositories/schema/libraryMembership.sql'; import { publishedRecords } from '../../infrastructure/repositories/schema/publishedRecord.sql'; import { CardBuilder } from '../utils/builders/CardBuilder'; import { CollectionBuilder } from '../utils/builders/CollectionBuilder'; import { URL } from '../../domain/value-objects/URL'; import { createTestSchema } from '../test-utils/createTestSchema'; import { CardTypeEnum } from '../../domain/value-objects/CardType'; import { PublishedRecordId } from '../../domain/value-objects/PublishedRecordId'; import { CollectionSortField, SortOrder, } from '../../domain/ICollectionQueryRepository'; describe('DrizzleCollectionQueryRepository - getCollectionsWithUrl', () => { let container: StartedPostgreSqlContainer; let db: PostgresJsDatabase; let queryRepository: DrizzleCollectionQueryRepository; let cardRepository: DrizzleCardRepository; let collectionRepository: DrizzleCollectionRepository; // Test data let curator1: CuratorId; let curator2: CuratorId; let curator3: CuratorId; // Setup before all tests beforeAll(async () => { // Start PostgreSQL container container = await new PostgreSqlContainer('postgres:14').start(); // Create database connection const connectionString = container.getConnectionUri(); process.env.DATABASE_URL = connectionString; const client = postgres(connectionString); db = drizzle(client); // Create repositories queryRepository = new DrizzleCollectionQueryRepository(db); cardRepository = new DrizzleCardRepository(db); collectionRepository = new DrizzleCollectionRepository(db); // Create schema using helper function await createTestSchema(db); // Create test data curator1 = CuratorId.create('did:plc:curator1').unwrap(); curator2 = CuratorId.create('did:plc:curator2').unwrap(); curator3 = CuratorId.create('did:plc:curator3').unwrap(); }, 60000); // Increase timeout for container startup // Cleanup after all tests afterAll(async () => { // Stop container await container.stop(); }); // Clear data between tests beforeEach(async () => { await db.delete(collectionCards); await db.delete(collections); await db.delete(libraryMemberships); await db.delete(cards); await db.delete(publishedRecords); }); describe('Collections with URL cards', () => { it('should return all collections containing cards with the specified URL', async () => { const testUrl = 'https://example.com/shared-article'; const url = URL.create(testUrl).unwrap(); // Create URL cards for different users with the same URL const card1 = new CardBuilder() .withCuratorId(curator1.value) .withType(CardTypeEnum.URL) .withUrl(url) .buildOrThrow(); const card2 = new CardBuilder() .withCuratorId(curator2.value) .withType(CardTypeEnum.URL) .withUrl(url) .buildOrThrow(); const card3 = new CardBuilder() .withCuratorId(curator3.value) .withType(CardTypeEnum.URL) .withUrl(url) .buildOrThrow(); // Add cards to their respective libraries card1.addToLibrary(curator1); card2.addToLibrary(curator2); card3.addToLibrary(curator3); await cardRepository.save(card1); await cardRepository.save(card2); await cardRepository.save(card3); // Create collections for each user and add their cards const collection1 = new CollectionBuilder() .withAuthorId(curator1.value) .withName('Tech Articles') .withDescription('My tech articles') .buildOrThrow(); const collection2 = new CollectionBuilder() .withAuthorId(curator2.value) .withName('Reading List') .withDescription('Articles to read') .buildOrThrow(); const collection3 = new CollectionBuilder() .withAuthorId(curator3.value) .withName('Favorites') .buildOrThrow(); // Add cards to collections collection1.addCard(card1.cardId, curator1); collection2.addCard(card2.cardId, curator2); collection3.addCard(card3.cardId, curator3); // Mark collections as published const publishedRecordId1 = PublishedRecordId.create({ uri: 'at://did:plc:curator1/network.cosmik.collection/collection1', cid: 'bafyreicollection1', }); const publishedRecordId2 = PublishedRecordId.create({ uri: 'at://did:plc:curator2/network.cosmik.collection/collection2', cid: 'bafyreicollection2', }); const publishedRecordId3 = PublishedRecordId.create({ uri: 'at://did:plc:curator3/network.cosmik.collection/collection3', cid: 'bafyreicollection3', }); collection1.markAsPublished(publishedRecordId1); collection2.markAsPublished(publishedRecordId2); collection3.markAsPublished(publishedRecordId3); await collectionRepository.save(collection1); await collectionRepository.save(collection2); await collectionRepository.save(collection3); // Execute the query const result = await queryRepository.getCollectionsWithUrl(testUrl, { page: 1, limit: 10, sortBy: CollectionSortField.NAME, sortOrder: SortOrder.ASC, }); // Verify the result 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.items.map((c) => c.id); expect(collectionIds).toContain( collection1.collectionId.getStringValue(), ); expect(collectionIds).toContain( collection2.collectionId.getStringValue(), ); expect(collectionIds).toContain( collection3.collectionId.getStringValue(), ); // Verify collection details 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); expect(techArticles?.uri).toBe( 'at://did:plc:curator1/network.cosmik.collection/collection1', ); 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.items.find((c) => c.name === 'Favorites'); expect(favorites).toBeDefined(); expect(favorites?.description).toBeUndefined(); expect(favorites?.authorId).toBe(curator3.value); }); 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, { page: 1, limit: 10, sortBy: CollectionSortField.NAME, sortOrder: SortOrder.ASC, }); 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 () => { const testUrl1 = 'https://example.com/article1'; const testUrl2 = 'https://example.com/article2'; const url1 = URL.create(testUrl1).unwrap(); const url2 = URL.create(testUrl2).unwrap(); // Create cards with different URLs const card1 = new CardBuilder() .withCuratorId(curator1.value) .withType(CardTypeEnum.URL) .withUrl(url1) .buildOrThrow(); const card2 = new CardBuilder() .withCuratorId(curator2.value) .withType(CardTypeEnum.URL) .withUrl(url2) .buildOrThrow(); card1.addToLibrary(curator1); card2.addToLibrary(curator2); await cardRepository.save(card1); await cardRepository.save(card2); // Create collections const collection1 = new CollectionBuilder() .withAuthorId(curator1.value) .withName('Collection 1') .buildOrThrow(); const collection2 = new CollectionBuilder() .withAuthorId(curator2.value) .withName('Collection 2') .buildOrThrow(); collection1.addCard(card1.cardId, curator1); collection2.addCard(card2.cardId, curator2); await collectionRepository.save(collection1); await collectionRepository.save(collection2); // Query for testUrl1 const result = await queryRepository.getCollectionsWithUrl(testUrl1, { page: 1, limit: 10, sortBy: CollectionSortField.NAME, sortOrder: SortOrder.ASC, }); 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 () => { const testUrl = 'https://example.com/popular-article'; const url = URL.create(testUrl).unwrap(); // Create URL card const card = new CardBuilder() .withCuratorId(curator1.value) .withType(CardTypeEnum.URL) .withUrl(url) .buildOrThrow(); card.addToLibrary(curator1); await cardRepository.save(card); // Create multiple collections for the same user const collection1 = new CollectionBuilder() .withAuthorId(curator1.value) .withName('Tech') .buildOrThrow(); const collection2 = new CollectionBuilder() .withAuthorId(curator1.value) .withName('Favorites') .buildOrThrow(); const collection3 = new CollectionBuilder() .withAuthorId(curator1.value) .withName('To Read') .buildOrThrow(); // Add the same card to all collections collection1.addCard(card.cardId, curator1); collection2.addCard(card.cardId, curator1); collection3.addCard(card.cardId, curator1); await collectionRepository.save(collection1); await collectionRepository.save(collection2); await collectionRepository.save(collection3); // Execute the query const result = await queryRepository.getCollectionsWithUrl(testUrl, { page: 1, limit: 10, sortBy: CollectionSortField.NAME, sortOrder: SortOrder.ASC, }); expect(result.items).toHaveLength(3); 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.items.forEach((collection) => { expect(collection.authorId).toBe(curator1.value); }); }); it('should handle collections without published record IDs', async () => { const testUrl = 'https://example.com/unpublished-article'; const url = URL.create(testUrl).unwrap(); // Create URL card const card = new CardBuilder() .withCuratorId(curator1.value) .withType(CardTypeEnum.URL) .withUrl(url) .buildOrThrow(); card.addToLibrary(curator1); await cardRepository.save(card); // Create collection without publishing it const collection = new CollectionBuilder() .withAuthorId(curator1.value) .withName('Unpublished Collection') .buildOrThrow(); collection.addCard(card.cardId, curator1); await collectionRepository.save(collection); // Execute the query const result = await queryRepository.getCollectionsWithUrl(testUrl, { page: 1, limit: 10, sortBy: CollectionSortField.NAME, sortOrder: SortOrder.ASC, }); 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 () => { const testUrl = 'https://example.com/shared-article'; const url = URL.create(testUrl).unwrap(); // Create URL cards for different users with the same URL const card1 = new CardBuilder() .withCuratorId(curator1.value) .withType(CardTypeEnum.URL) .withUrl(url) .buildOrThrow(); const card2 = new CardBuilder() .withCuratorId(curator2.value) .withType(CardTypeEnum.URL) .withUrl(url) .buildOrThrow(); card1.addToLibrary(curator1); card2.addToLibrary(curator2); await cardRepository.save(card1); await cardRepository.save(card2); // Create one collection that contains both cards const collection = new CollectionBuilder() .withAuthorId(curator1.value) .withName('Shared Collection') .buildOrThrow(); collection.addCard(card1.cardId, curator1); collection.addCard(card2.cardId, curator1); await collectionRepository.save(collection); // Execute the query const result = await queryRepository.getCollectionsWithUrl(testUrl, { page: 1, limit: 10, sortBy: CollectionSortField.NAME, sortOrder: SortOrder.ASC, }); // Should return the collection only once, even though it has multiple cards with the URL 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 () => { const testUrl = 'https://example.com/article'; const url = URL.create(testUrl).unwrap(); // Create URL card const urlCard = new CardBuilder() .withCuratorId(curator1.value) .withType(CardTypeEnum.URL) .withUrl(url) .buildOrThrow(); // Create NOTE card with same URL (edge case) const noteCard = new CardBuilder() .withCuratorId(curator2.value) .withType(CardTypeEnum.NOTE) .withUrl(url) .buildOrThrow(); urlCard.addToLibrary(curator1); noteCard.addToLibrary(curator2); await cardRepository.save(urlCard); await cardRepository.save(noteCard); // Create collections const collection1 = new CollectionBuilder() .withAuthorId(curator1.value) .withName('URL Collection') .buildOrThrow(); const collection2 = new CollectionBuilder() .withAuthorId(curator2.value) .withName('Note Collection') .buildOrThrow(); collection1.addCard(urlCard.cardId, curator1); collection2.addCard(noteCard.cardId, curator2); await collectionRepository.save(collection1); await collectionRepository.save(collection2); const result = await queryRepository.getCollectionsWithUrl(testUrl, { page: 1, limit: 10, sortBy: CollectionSortField.NAME, sortOrder: SortOrder.ASC, }); // Should only return the collection with the URL card, not the NOTE card 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 () => { const testUrl = 'https://example.com/article'; const url = URL.create(testUrl).unwrap(); // Create URL card but don't add to any collection const card = new CardBuilder() .withCuratorId(curator1.value) .withType(CardTypeEnum.URL) .withUrl(url) .buildOrThrow(); card.addToLibrary(curator1); await cardRepository.save(card); const result = await queryRepository.getCollectionsWithUrl(testUrl, { page: 1, limit: 10, sortBy: CollectionSortField.NAME, sortOrder: SortOrder.ASC, }); // Should return empty since card is not in any collection expect(result.items).toHaveLength(0); }); it('should return collections sorted alphabetically by name in ascending order', async () => { const testUrl = 'https://example.com/article'; const url = URL.create(testUrl).unwrap(); // Create URL cards const card1 = new CardBuilder() .withCuratorId(curator1.value) .withType(CardTypeEnum.URL) .withUrl(url) .buildOrThrow(); const card2 = new CardBuilder() .withCuratorId(curator2.value) .withType(CardTypeEnum.URL) .withUrl(url) .buildOrThrow(); const card3 = new CardBuilder() .withCuratorId(curator3.value) .withType(CardTypeEnum.URL) .withUrl(url) .buildOrThrow(); card1.addToLibrary(curator1); card2.addToLibrary(curator2); card3.addToLibrary(curator3); await cardRepository.save(card1); await cardRepository.save(card2); await cardRepository.save(card3); // Create collections with names that should be sorted const collectionZ = new CollectionBuilder() .withAuthorId(curator1.value) .withName('Zebra Collection') .buildOrThrow(); const collectionA = new CollectionBuilder() .withAuthorId(curator2.value) .withName('Apple Collection') .buildOrThrow(); const collectionM = new CollectionBuilder() .withAuthorId(curator3.value) .withName('Mango Collection') .buildOrThrow(); collectionZ.addCard(card1.cardId, curator1); collectionA.addCard(card2.cardId, curator2); collectionM.addCard(card3.cardId, curator3); await collectionRepository.save(collectionZ); await collectionRepository.save(collectionA); await collectionRepository.save(collectionM); const result = await queryRepository.getCollectionsWithUrl(testUrl, { page: 1, limit: 10, sortBy: CollectionSortField.NAME, sortOrder: SortOrder.ASC, }); 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'); }); it('should return collections sorted alphabetically by name in descending order', async () => { const testUrl = 'https://example.com/article'; const url = URL.create(testUrl).unwrap(); // Create URL cards const card1 = new CardBuilder() .withCuratorId(curator1.value) .withType(CardTypeEnum.URL) .withUrl(url) .buildOrThrow(); const card2 = new CardBuilder() .withCuratorId(curator2.value) .withType(CardTypeEnum.URL) .withUrl(url) .buildOrThrow(); const card3 = new CardBuilder() .withCuratorId(curator3.value) .withType(CardTypeEnum.URL) .withUrl(url) .buildOrThrow(); card1.addToLibrary(curator1); card2.addToLibrary(curator2); card3.addToLibrary(curator3); await cardRepository.save(card1); await cardRepository.save(card2); await cardRepository.save(card3); // Create collections with names that should be sorted const collectionZ = new CollectionBuilder() .withAuthorId(curator1.value) .withName('Zebra Collection') .buildOrThrow(); const collectionA = new CollectionBuilder() .withAuthorId(curator2.value) .withName('Apple Collection') .buildOrThrow(); const collectionM = new CollectionBuilder() .withAuthorId(curator3.value) .withName('Mango Collection') .buildOrThrow(); collectionZ.addCard(card1.cardId, curator1); collectionA.addCard(card2.cardId, curator2); collectionM.addCard(card3.cardId, curator3); await collectionRepository.save(collectionZ); await collectionRepository.save(collectionA); await collectionRepository.save(collectionM); const result = await queryRepository.getCollectionsWithUrl(testUrl, { page: 1, limit: 10, sortBy: CollectionSortField.NAME, sortOrder: SortOrder.DESC, }); expect(result.items).toHaveLength(3); expect(result.items[0]!.name).toBe('Zebra Collection'); expect(result.items[1]!.name).toBe('Mango Collection'); expect(result.items[2]!.name).toBe('Apple 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, sortBy: CollectionSortField.NAME, sortOrder: SortOrder.ASC, }); 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, sortBy: CollectionSortField.NAME, sortOrder: SortOrder.ASC, }); 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, sortBy: CollectionSortField.NAME, sortOrder: SortOrder.ASC, }); 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.id), ]; 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, sortBy: CollectionSortField.NAME, sortOrder: SortOrder.ASC, }); 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, sortBy: CollectionSortField.NAME, sortOrder: SortOrder.ASC, }); expect(result.items).toHaveLength(0); expect(result.totalCount).toBe(1); expect(result.hasMore).toBe(false); }); }); });