import { PostgreSqlContainer, StartedPostgreSqlContainer, } from '@testcontainers/postgresql'; import postgres from 'postgres'; import { drizzle, PostgresJsDatabase } from 'drizzle-orm/postgres-js'; import { DrizzleCardQueryRepository } from '../../infrastructure/repositories/DrizzleCardQueryRepository'; import { DrizzleCardRepository } from '../../infrastructure/repositories/DrizzleCardRepository'; import { CuratorId } from '../../domain/value-objects/CuratorId'; import { cards } from '../../infrastructure/repositories/schema/card.sql'; import { libraryMemberships } from '../../infrastructure/repositories/schema/libraryMembership.sql'; import { publishedRecords } from '../../infrastructure/repositories/schema/publishedRecord.sql'; import { CardBuilder } from '../utils/builders/CardBuilder'; import { URL } from '../../domain/value-objects/URL'; import { UrlMetadata } from '../../domain/value-objects/UrlMetadata'; import { CardSortField, SortOrder } from '../../domain/ICardQueryRepository'; import { createTestSchema } from '../test-utils/createTestSchema'; import { UrlType } from '../../domain/value-objects/UrlType'; describe('DrizzleCardQueryRepository - searchUrls', () => { let container: StartedPostgreSqlContainer; let db: PostgresJsDatabase; let queryRepository: DrizzleCardQueryRepository; let cardRepository: DrizzleCardRepository; // 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 DrizzleCardQueryRepository(db); cardRepository = new DrizzleCardRepository(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(libraryMemberships); await db.delete(cards); await db.delete(publishedRecords); }); describe('Basic search functionality', () => { it('should find URLs matching search query in title', async () => { const url1 = URL.create('https://example.com/quantum').unwrap(); const metadata1 = UrlMetadata.create({ url: url1.value, title: 'Introduction to Quantum Computing', description: 'A comprehensive guide', }).unwrap(); const url2 = URL.create('https://example.com/classical').unwrap(); const metadata2 = UrlMetadata.create({ url: url2.value, title: 'Classical Computing Basics', description: 'Learn the fundamentals', }).unwrap(); const card1 = new CardBuilder() .withCuratorId(curator1.value) .withUrlCard(url1, metadata1) .buildOrThrow(); const card2 = new CardBuilder() .withCuratorId(curator1.value) .withUrlCard(url2, metadata2) .buildOrThrow(); card1.addToLibrary(curator1); card2.addToLibrary(curator1); await cardRepository.save(card1); await cardRepository.save(card2); const result = await queryRepository.searchUrls({ searchQuery: 'quantum', page: 1, limit: 10, sortBy: CardSortField.UPDATED_AT, sortOrder: SortOrder.DESC, }); expect(result.items).toHaveLength(1); expect(result.items[0]!.url).toBe(url1.value); expect(result.items[0]!.contentData.metadata.title).toBe( 'Introduction to Quantum Computing', ); expect(result.totalCount).toBe(1); }); it('should find URLs matching search query in description', async () => { const url1 = URL.create('https://example.com/article1').unwrap(); const metadata1 = UrlMetadata.create({ url: url1.value, title: 'Article One', description: 'This discusses machine learning algorithms', }).unwrap(); const url2 = URL.create('https://example.com/article2').unwrap(); const metadata2 = UrlMetadata.create({ url: url2.value, title: 'Article Two', description: 'A different topic entirely', }).unwrap(); const card1 = new CardBuilder() .withCuratorId(curator1.value) .withUrlCard(url1, metadata1) .buildOrThrow(); const card2 = new CardBuilder() .withCuratorId(curator1.value) .withUrlCard(url2, metadata2) .buildOrThrow(); card1.addToLibrary(curator1); card2.addToLibrary(curator1); await cardRepository.save(card1); await cardRepository.save(card2); const result = await queryRepository.searchUrls({ searchQuery: 'machine learning', page: 1, limit: 10, sortBy: CardSortField.UPDATED_AT, sortOrder: SortOrder.DESC, }); expect(result.items).toHaveLength(1); expect(result.items[0]!.url).toBe(url1.value); }); it('should find URLs matching search query in URL field', async () => { const url1 = URL.create('https://github.com/example/repo').unwrap(); const metadata1 = UrlMetadata.create({ url: url1.value, title: 'Example Repository', description: 'A code repository', }).unwrap(); const url2 = URL.create('https://gitlab.com/example/repo').unwrap(); const metadata2 = UrlMetadata.create({ url: url2.value, title: 'Another Repository', description: 'Another code repository', }).unwrap(); const card1 = new CardBuilder() .withCuratorId(curator1.value) .withUrlCard(url1, metadata1) .buildOrThrow(); const card2 = new CardBuilder() .withCuratorId(curator1.value) .withUrlCard(url2, metadata2) .buildOrThrow(); card1.addToLibrary(curator1); card2.addToLibrary(curator1); await cardRepository.save(card1); await cardRepository.save(card2); const result = await queryRepository.searchUrls({ searchQuery: 'github.com', page: 1, limit: 10, sortBy: CardSortField.UPDATED_AT, sortOrder: SortOrder.DESC, }); expect(result.items).toHaveLength(1); expect(result.items[0]!.url).toBe(url1.value); }); }); describe('Tokenized substring search', () => { it('should match all words in query across different fields', async () => { const url1 = URL.create('https://example.com/article1').unwrap(); const metadata1 = UrlMetadata.create({ url: url1.value, title: 'Machine Learning Fundamentals', description: 'Introduction to neural networks', }).unwrap(); const url2 = URL.create('https://example.com/article2').unwrap(); const metadata2 = UrlMetadata.create({ url: url2.value, title: 'Deep Learning Guide', description: 'Advanced machine learning techniques', }).unwrap(); const card1 = new CardBuilder() .withCuratorId(curator1.value) .withUrlCard(url1, metadata1) .buildOrThrow(); const card2 = new CardBuilder() .withCuratorId(curator1.value) .withUrlCard(url2, metadata2) .buildOrThrow(); card1.addToLibrary(curator1); card2.addToLibrary(curator1); await cardRepository.save(card1); await cardRepository.save(card2); // Search for "machine neural" - should only match card1 const result = await queryRepository.searchUrls({ searchQuery: 'machine neural', page: 1, limit: 10, sortBy: CardSortField.UPDATED_AT, sortOrder: SortOrder.DESC, }); expect(result.items).toHaveLength(1); expect(result.items[0]!.url).toBe(url1.value); }); it('should use substring matching, not full word matching', async () => { const url1 = URL.create('https://example.com/article').unwrap(); const metadata1 = UrlMetadata.create({ url: url1.value, title: 'Understanding Programming', description: 'A guide', }).unwrap(); const card1 = new CardBuilder() .withCuratorId(curator1.value) .withUrlCard(url1, metadata1) .buildOrThrow(); card1.addToLibrary(curator1); await cardRepository.save(card1); // Search for "program" - should match "Programming" via substring const result = await queryRepository.searchUrls({ searchQuery: 'program', page: 1, limit: 10, sortBy: CardSortField.UPDATED_AT, sortOrder: SortOrder.DESC, }); expect(result.items).toHaveLength(1); expect(result.items[0]!.url).toBe(url1.value); }); }); describe('URL deduplication', () => { it('should deduplicate same URL from different users and return most recent', async () => { const sharedUrl = URL.create('https://example.com/shared').unwrap(); // Curator 1 creates card first (older) const metadata1 = UrlMetadata.create({ url: sharedUrl.value, title: 'Shared Article', description: 'Version 1', }).unwrap(); const card1 = new CardBuilder() .withCuratorId(curator1.value) .withUrlCard(sharedUrl, metadata1) .withCreatedAt(new Date('2023-01-01')) .withUpdatedAt(new Date('2023-01-01')) .buildOrThrow(); card1.addToLibrary(curator1); await cardRepository.save(card1); // Wait a bit to ensure different timestamp await new Promise((resolve) => setTimeout(resolve, 10)); // Curator 2 creates card with same URL (newer) const metadata2 = UrlMetadata.create({ url: sharedUrl.value, title: 'Shared Article', description: 'Version 2 - Updated', }).unwrap(); const card2 = new CardBuilder() .withCuratorId(curator2.value) .withUrlCard(sharedUrl, metadata2) .withCreatedAt(new Date('2023-01-02')) .withUpdatedAt(new Date('2023-01-02')) .buildOrThrow(); card2.addToLibrary(curator2); await cardRepository.save(card2); const result = await queryRepository.searchUrls({ searchQuery: 'shared', page: 1, limit: 10, sortBy: CardSortField.UPDATED_AT, sortOrder: SortOrder.DESC, }); // Should return only one URL (deduplicated) expect(result.items).toHaveLength(1); expect(result.items[0]!.url).toBe(sharedUrl.value); // Should have the newer metadata expect(result.items[0]!.contentData.metadata.description).toBe( 'Version 2 - Updated', ); expect(result.totalCount).toBe(1); }); }); describe('URL type filtering', () => { it('should filter by URL type when specified', async () => { const articleUrl = URL.create('https://example.com/article').unwrap(); const articleMetadata = UrlMetadata.create({ url: articleUrl.value, title: 'Test Article', description: 'An article', type: UrlType.ARTICLE, }).unwrap(); const videoUrl = URL.create('https://example.com/video').unwrap(); const videoMetadata = UrlMetadata.create({ url: videoUrl.value, title: 'Test Video', description: 'A video', type: UrlType.VIDEO, }).unwrap(); const articleCard = new CardBuilder() .withCuratorId(curator1.value) .withUrlCard(articleUrl, articleMetadata) .buildOrThrow(); const videoCard = new CardBuilder() .withCuratorId(curator1.value) .withUrlCard(videoUrl, videoMetadata) .buildOrThrow(); articleCard.addToLibrary(curator1); videoCard.addToLibrary(curator1); await cardRepository.save(articleCard); await cardRepository.save(videoCard); // Search for articles only const articleResult = await queryRepository.searchUrls({ searchQuery: 'test', page: 1, limit: 10, sortBy: CardSortField.UPDATED_AT, sortOrder: SortOrder.DESC, urlType: UrlType.ARTICLE, }); expect(articleResult.items).toHaveLength(1); expect(articleResult.items[0]!.url).toBe(articleUrl.value); // Search for videos only const videoResult = await queryRepository.searchUrls({ searchQuery: 'test', page: 1, limit: 10, sortBy: CardSortField.UPDATED_AT, sortOrder: SortOrder.DESC, urlType: UrlType.VIDEO, }); expect(videoResult.items).toHaveLength(1); expect(videoResult.items[0]!.url).toBe(videoUrl.value); }); }); describe('Pagination', () => { it('should paginate results correctly', async () => { // Create 5 cards for (let i = 1; i <= 5; i++) { const url = URL.create(`https://example.com/article${i}`).unwrap(); const metadata = UrlMetadata.create({ url: url.value, title: `Article ${i}`, description: 'Test article', }).unwrap(); const card = new CardBuilder() .withCuratorId(curator1.value) .withUrlCard(url, metadata) .buildOrThrow(); card.addToLibrary(curator1); await cardRepository.save(card); } // First page with limit 2 const page1 = await queryRepository.searchUrls({ searchQuery: 'article', page: 1, limit: 2, sortBy: CardSortField.UPDATED_AT, sortOrder: SortOrder.DESC, }); expect(page1.items).toHaveLength(2); expect(page1.totalCount).toBe(5); expect(page1.hasMore).toBe(true); // Second page const page2 = await queryRepository.searchUrls({ searchQuery: 'article', page: 2, limit: 2, sortBy: CardSortField.UPDATED_AT, sortOrder: SortOrder.DESC, }); expect(page2.items).toHaveLength(2); expect(page2.totalCount).toBe(5); expect(page2.hasMore).toBe(true); // Third page (last page with 1 item) const page3 = await queryRepository.searchUrls({ searchQuery: 'article', page: 3, limit: 2, sortBy: CardSortField.UPDATED_AT, sortOrder: SortOrder.DESC, }); expect(page3.items).toHaveLength(1); expect(page3.totalCount).toBe(5); expect(page3.hasMore).toBe(false); }); }); describe('Empty results', () => { it('should return empty results when no matches found', async () => { const url1 = URL.create('https://example.com/article').unwrap(); const metadata1 = UrlMetadata.create({ url: url1.value, title: 'Test Article', description: 'A test', }).unwrap(); const card1 = new CardBuilder() .withCuratorId(curator1.value) .withUrlCard(url1, metadata1) .buildOrThrow(); card1.addToLibrary(curator1); await cardRepository.save(card1); const result = await queryRepository.searchUrls({ searchQuery: 'nonexistent', page: 1, limit: 10, sortBy: CardSortField.UPDATED_AT, sortOrder: SortOrder.DESC, }); expect(result.items).toHaveLength(0); expect(result.totalCount).toBe(0); expect(result.hasMore).toBe(false); }); it('should return empty results when no cards exist', async () => { const result = await queryRepository.searchUrls({ searchQuery: 'anything', page: 1, limit: 10, sortBy: CardSortField.UPDATED_AT, sortOrder: SortOrder.DESC, }); expect(result.items).toHaveLength(0); expect(result.totalCount).toBe(0); expect(result.hasMore).toBe(false); }); }); describe('Case insensitive search', () => { it('should match regardless of case', async () => { const url1 = URL.create('https://example.com/article').unwrap(); const metadata1 = UrlMetadata.create({ url: url1.value, title: 'UPPERCASE TITLE', description: 'lowercase description', }).unwrap(); const card1 = new CardBuilder() .withCuratorId(curator1.value) .withUrlCard(url1, metadata1) .buildOrThrow(); card1.addToLibrary(curator1); await cardRepository.save(card1); // Search with lowercase const result1 = await queryRepository.searchUrls({ searchQuery: 'uppercase', page: 1, limit: 10, sortBy: CardSortField.UPDATED_AT, sortOrder: SortOrder.DESC, }); expect(result1.items).toHaveLength(1); // Search with mixed case const result2 = await queryRepository.searchUrls({ searchQuery: 'LOWERCASE', page: 1, limit: 10, sortBy: CardSortField.UPDATED_AT, sortOrder: SortOrder.DESC, }); expect(result2.items).toHaveLength(1); }); }); });