This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

semble / src / modules / cards / tests / infrastructure / DrizzleCardQueryRepository.searchUrls.integration.test.ts
17 kB 546 lines
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 { UrlMetadata } from '../../domain/value-objects/UrlMetadata'; 16import { CardSortField, SortOrder } from '../../domain/ICardQueryRepository'; 17import { createTestSchema } from '../test-utils/createTestSchema'; 18import { UrlType } from '../../domain/value-objects/UrlType'; 19 20describe('DrizzleCardQueryRepository - searchUrls', () => { 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('Basic search functionality', () => { 69 it('should find URLs matching search query in title', async () => { 70 const url1 = URL.create('https://example.com/quantum').unwrap(); 71 const metadata1 = UrlMetadata.create({ 72 url: url1.value, 73 title: 'Introduction to Quantum Computing', 74 description: 'A comprehensive guide', 75 }).unwrap(); 76 77 const url2 = URL.create('https://example.com/classical').unwrap(); 78 const metadata2 = UrlMetadata.create({ 79 url: url2.value, 80 title: 'Classical Computing Basics', 81 description: 'Learn the fundamentals', 82 }).unwrap(); 83 84 const card1 = new CardBuilder() 85 .withCuratorId(curator1.value) 86 .withUrlCard(url1, metadata1) 87 .buildOrThrow(); 88 89 const card2 = new CardBuilder() 90 .withCuratorId(curator1.value) 91 .withUrlCard(url2, metadata2) 92 .buildOrThrow(); 93 94 card1.addToLibrary(curator1); 95 card2.addToLibrary(curator1); 96 97 await cardRepository.save(card1); 98 await cardRepository.save(card2); 99 100 const result = await queryRepository.searchUrls({ 101 searchQuery: 'quantum', 102 page: 1, 103 limit: 10, 104 sortBy: CardSortField.UPDATED_AT, 105 sortOrder: SortOrder.DESC, 106 }); 107 108 expect(result.items).toHaveLength(1); 109 expect(result.items[0]!.url).toBe(url1.value); 110 expect(result.items[0]!.contentData.metadata.title).toBe( 111 'Introduction to Quantum Computing', 112 ); 113 expect(result.totalCount).toBe(1); 114 }); 115 116 it('should find URLs matching search query in description', async () => { 117 const url1 = URL.create('https://example.com/article1').unwrap(); 118 const metadata1 = UrlMetadata.create({ 119 url: url1.value, 120 title: 'Article One', 121 description: 'This discusses machine learning algorithms', 122 }).unwrap(); 123 124 const url2 = URL.create('https://example.com/article2').unwrap(); 125 const metadata2 = UrlMetadata.create({ 126 url: url2.value, 127 title: 'Article Two', 128 description: 'A different topic entirely', 129 }).unwrap(); 130 131 const card1 = new CardBuilder() 132 .withCuratorId(curator1.value) 133 .withUrlCard(url1, metadata1) 134 .buildOrThrow(); 135 136 const card2 = new CardBuilder() 137 .withCuratorId(curator1.value) 138 .withUrlCard(url2, metadata2) 139 .buildOrThrow(); 140 141 card1.addToLibrary(curator1); 142 card2.addToLibrary(curator1); 143 144 await cardRepository.save(card1); 145 await cardRepository.save(card2); 146 147 const result = await queryRepository.searchUrls({ 148 searchQuery: 'machine learning', 149 page: 1, 150 limit: 10, 151 sortBy: CardSortField.UPDATED_AT, 152 sortOrder: SortOrder.DESC, 153 }); 154 155 expect(result.items).toHaveLength(1); 156 expect(result.items[0]!.url).toBe(url1.value); 157 }); 158 159 it('should find URLs matching search query in URL field', async () => { 160 const url1 = URL.create('https://github.com/example/repo').unwrap(); 161 const metadata1 = UrlMetadata.create({ 162 url: url1.value, 163 title: 'Example Repository', 164 description: 'A code repository', 165 }).unwrap(); 166 167 const url2 = URL.create('https://gitlab.com/example/repo').unwrap(); 168 const metadata2 = UrlMetadata.create({ 169 url: url2.value, 170 title: 'Another Repository', 171 description: 'Another code repository', 172 }).unwrap(); 173 174 const card1 = new CardBuilder() 175 .withCuratorId(curator1.value) 176 .withUrlCard(url1, metadata1) 177 .buildOrThrow(); 178 179 const card2 = new CardBuilder() 180 .withCuratorId(curator1.value) 181 .withUrlCard(url2, metadata2) 182 .buildOrThrow(); 183 184 card1.addToLibrary(curator1); 185 card2.addToLibrary(curator1); 186 187 await cardRepository.save(card1); 188 await cardRepository.save(card2); 189 190 const result = await queryRepository.searchUrls({ 191 searchQuery: 'github.com', 192 page: 1, 193 limit: 10, 194 sortBy: CardSortField.UPDATED_AT, 195 sortOrder: SortOrder.DESC, 196 }); 197 198 expect(result.items).toHaveLength(1); 199 expect(result.items[0]!.url).toBe(url1.value); 200 }); 201 }); 202 203 describe('Tokenized substring search', () => { 204 it('should match all words in query across different fields', async () => { 205 const url1 = URL.create('https://example.com/article1').unwrap(); 206 const metadata1 = UrlMetadata.create({ 207 url: url1.value, 208 title: 'Machine Learning Fundamentals', 209 description: 'Introduction to neural networks', 210 }).unwrap(); 211 212 const url2 = URL.create('https://example.com/article2').unwrap(); 213 const metadata2 = UrlMetadata.create({ 214 url: url2.value, 215 title: 'Deep Learning Guide', 216 description: 'Advanced machine learning techniques', 217 }).unwrap(); 218 219 const card1 = new CardBuilder() 220 .withCuratorId(curator1.value) 221 .withUrlCard(url1, metadata1) 222 .buildOrThrow(); 223 224 const card2 = new CardBuilder() 225 .withCuratorId(curator1.value) 226 .withUrlCard(url2, metadata2) 227 .buildOrThrow(); 228 229 card1.addToLibrary(curator1); 230 card2.addToLibrary(curator1); 231 232 await cardRepository.save(card1); 233 await cardRepository.save(card2); 234 235 // Search for "machine neural" - should only match card1 236 const result = await queryRepository.searchUrls({ 237 searchQuery: 'machine neural', 238 page: 1, 239 limit: 10, 240 sortBy: CardSortField.UPDATED_AT, 241 sortOrder: SortOrder.DESC, 242 }); 243 244 expect(result.items).toHaveLength(1); 245 expect(result.items[0]!.url).toBe(url1.value); 246 }); 247 248 it('should use substring matching, not full word matching', async () => { 249 const url1 = URL.create('https://example.com/article').unwrap(); 250 const metadata1 = UrlMetadata.create({ 251 url: url1.value, 252 title: 'Understanding Programming', 253 description: 'A guide', 254 }).unwrap(); 255 256 const card1 = new CardBuilder() 257 .withCuratorId(curator1.value) 258 .withUrlCard(url1, metadata1) 259 .buildOrThrow(); 260 261 card1.addToLibrary(curator1); 262 await cardRepository.save(card1); 263 264 // Search for "program" - should match "Programming" via substring 265 const result = await queryRepository.searchUrls({ 266 searchQuery: 'program', 267 page: 1, 268 limit: 10, 269 sortBy: CardSortField.UPDATED_AT, 270 sortOrder: SortOrder.DESC, 271 }); 272 273 expect(result.items).toHaveLength(1); 274 expect(result.items[0]!.url).toBe(url1.value); 275 }); 276 }); 277 278 describe('URL deduplication', () => { 279 it('should deduplicate same URL from different users and return most recent', async () => { 280 const sharedUrl = URL.create('https://example.com/shared').unwrap(); 281 282 // Curator 1 creates card first (older) 283 const metadata1 = UrlMetadata.create({ 284 url: sharedUrl.value, 285 title: 'Shared Article', 286 description: 'Version 1', 287 }).unwrap(); 288 289 const card1 = new CardBuilder() 290 .withCuratorId(curator1.value) 291 .withUrlCard(sharedUrl, metadata1) 292 .withCreatedAt(new Date('2023-01-01')) 293 .withUpdatedAt(new Date('2023-01-01')) 294 .buildOrThrow(); 295 296 card1.addToLibrary(curator1); 297 await cardRepository.save(card1); 298 299 // Wait a bit to ensure different timestamp 300 await new Promise((resolve) => setTimeout(resolve, 10)); 301 302 // Curator 2 creates card with same URL (newer) 303 const metadata2 = UrlMetadata.create({ 304 url: sharedUrl.value, 305 title: 'Shared Article', 306 description: 'Version 2 - Updated', 307 }).unwrap(); 308 309 const card2 = new CardBuilder() 310 .withCuratorId(curator2.value) 311 .withUrlCard(sharedUrl, metadata2) 312 .withCreatedAt(new Date('2023-01-02')) 313 .withUpdatedAt(new Date('2023-01-02')) 314 .buildOrThrow(); 315 316 card2.addToLibrary(curator2); 317 await cardRepository.save(card2); 318 319 const result = await queryRepository.searchUrls({ 320 searchQuery: 'shared', 321 page: 1, 322 limit: 10, 323 sortBy: CardSortField.UPDATED_AT, 324 sortOrder: SortOrder.DESC, 325 }); 326 327 // Should return only one URL (deduplicated) 328 expect(result.items).toHaveLength(1); 329 expect(result.items[0]!.url).toBe(sharedUrl.value); 330 // Should have the newer metadata 331 expect(result.items[0]!.contentData.metadata.description).toBe( 332 'Version 2 - Updated', 333 ); 334 expect(result.totalCount).toBe(1); 335 }); 336 }); 337 338 describe('URL type filtering', () => { 339 it('should filter by URL type when specified', async () => { 340 const articleUrl = URL.create('https://example.com/article').unwrap(); 341 const articleMetadata = UrlMetadata.create({ 342 url: articleUrl.value, 343 title: 'Test Article', 344 description: 'An article', 345 type: UrlType.ARTICLE, 346 }).unwrap(); 347 348 const videoUrl = URL.create('https://example.com/video').unwrap(); 349 const videoMetadata = UrlMetadata.create({ 350 url: videoUrl.value, 351 title: 'Test Video', 352 description: 'A video', 353 type: UrlType.VIDEO, 354 }).unwrap(); 355 356 const articleCard = new CardBuilder() 357 .withCuratorId(curator1.value) 358 .withUrlCard(articleUrl, articleMetadata) 359 .buildOrThrow(); 360 361 const videoCard = new CardBuilder() 362 .withCuratorId(curator1.value) 363 .withUrlCard(videoUrl, videoMetadata) 364 .buildOrThrow(); 365 366 articleCard.addToLibrary(curator1); 367 videoCard.addToLibrary(curator1); 368 369 await cardRepository.save(articleCard); 370 await cardRepository.save(videoCard); 371 372 // Search for articles only 373 const articleResult = await queryRepository.searchUrls({ 374 searchQuery: 'test', 375 page: 1, 376 limit: 10, 377 sortBy: CardSortField.UPDATED_AT, 378 sortOrder: SortOrder.DESC, 379 urlType: UrlType.ARTICLE, 380 }); 381 382 expect(articleResult.items).toHaveLength(1); 383 expect(articleResult.items[0]!.url).toBe(articleUrl.value); 384 385 // Search for videos only 386 const videoResult = await queryRepository.searchUrls({ 387 searchQuery: 'test', 388 page: 1, 389 limit: 10, 390 sortBy: CardSortField.UPDATED_AT, 391 sortOrder: SortOrder.DESC, 392 urlType: UrlType.VIDEO, 393 }); 394 395 expect(videoResult.items).toHaveLength(1); 396 expect(videoResult.items[0]!.url).toBe(videoUrl.value); 397 }); 398 }); 399 400 describe('Pagination', () => { 401 it('should paginate results correctly', async () => { 402 // Create 5 cards 403 for (let i = 1; i <= 5; i++) { 404 const url = URL.create(`https://example.com/article${i}`).unwrap(); 405 const metadata = UrlMetadata.create({ 406 url: url.value, 407 title: `Article ${i}`, 408 description: 'Test article', 409 }).unwrap(); 410 411 const card = new CardBuilder() 412 .withCuratorId(curator1.value) 413 .withUrlCard(url, metadata) 414 .buildOrThrow(); 415 416 card.addToLibrary(curator1); 417 await cardRepository.save(card); 418 } 419 420 // First page with limit 2 421 const page1 = await queryRepository.searchUrls({ 422 searchQuery: 'article', 423 page: 1, 424 limit: 2, 425 sortBy: CardSortField.UPDATED_AT, 426 sortOrder: SortOrder.DESC, 427 }); 428 429 expect(page1.items).toHaveLength(2); 430 expect(page1.totalCount).toBe(5); 431 expect(page1.hasMore).toBe(true); 432 433 // Second page 434 const page2 = await queryRepository.searchUrls({ 435 searchQuery: 'article', 436 page: 2, 437 limit: 2, 438 sortBy: CardSortField.UPDATED_AT, 439 sortOrder: SortOrder.DESC, 440 }); 441 442 expect(page2.items).toHaveLength(2); 443 expect(page2.totalCount).toBe(5); 444 expect(page2.hasMore).toBe(true); 445 446 // Third page (last page with 1 item) 447 const page3 = await queryRepository.searchUrls({ 448 searchQuery: 'article', 449 page: 3, 450 limit: 2, 451 sortBy: CardSortField.UPDATED_AT, 452 sortOrder: SortOrder.DESC, 453 }); 454 455 expect(page3.items).toHaveLength(1); 456 expect(page3.totalCount).toBe(5); 457 expect(page3.hasMore).toBe(false); 458 }); 459 }); 460 461 describe('Empty results', () => { 462 it('should return empty results when no matches found', async () => { 463 const url1 = URL.create('https://example.com/article').unwrap(); 464 const metadata1 = UrlMetadata.create({ 465 url: url1.value, 466 title: 'Test Article', 467 description: 'A test', 468 }).unwrap(); 469 470 const card1 = new CardBuilder() 471 .withCuratorId(curator1.value) 472 .withUrlCard(url1, metadata1) 473 .buildOrThrow(); 474 475 card1.addToLibrary(curator1); 476 await cardRepository.save(card1); 477 478 const result = await queryRepository.searchUrls({ 479 searchQuery: 'nonexistent', 480 page: 1, 481 limit: 10, 482 sortBy: CardSortField.UPDATED_AT, 483 sortOrder: SortOrder.DESC, 484 }); 485 486 expect(result.items).toHaveLength(0); 487 expect(result.totalCount).toBe(0); 488 expect(result.hasMore).toBe(false); 489 }); 490 491 it('should return empty results when no cards exist', async () => { 492 const result = await queryRepository.searchUrls({ 493 searchQuery: 'anything', 494 page: 1, 495 limit: 10, 496 sortBy: CardSortField.UPDATED_AT, 497 sortOrder: SortOrder.DESC, 498 }); 499 500 expect(result.items).toHaveLength(0); 501 expect(result.totalCount).toBe(0); 502 expect(result.hasMore).toBe(false); 503 }); 504 }); 505 506 describe('Case insensitive search', () => { 507 it('should match regardless of case', async () => { 508 const url1 = URL.create('https://example.com/article').unwrap(); 509 const metadata1 = UrlMetadata.create({ 510 url: url1.value, 511 title: 'UPPERCASE TITLE', 512 description: 'lowercase description', 513 }).unwrap(); 514 515 const card1 = new CardBuilder() 516 .withCuratorId(curator1.value) 517 .withUrlCard(url1, metadata1) 518 .buildOrThrow(); 519 520 card1.addToLibrary(curator1); 521 await cardRepository.save(card1); 522 523 // Search with lowercase 524 const result1 = await queryRepository.searchUrls({ 525 searchQuery: 'uppercase', 526 page: 1, 527 limit: 10, 528 sortBy: CardSortField.UPDATED_AT, 529 sortOrder: SortOrder.DESC, 530 }); 531 532 expect(result1.items).toHaveLength(1); 533 534 // Search with mixed case 535 const result2 = await queryRepository.searchUrls({ 536 searchQuery: 'LOWERCASE', 537 page: 1, 538 limit: 10, 539 sortBy: CardSortField.UPDATED_AT, 540 sortOrder: SortOrder.DESC, 541 }); 542 543 expect(result2.items).toHaveLength(1); 544 }); 545 }); 546});