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 / application / GetUrlSubGraphUseCase.integration.test.ts
22 kB 555 lines
1import { 2 PostgreSqlContainer, 3 StartedPostgreSqlContainer, 4} from '@testcontainers/postgresql'; 5import postgres from 'postgres'; 6import { drizzle, PostgresJsDatabase } from 'drizzle-orm/postgres-js'; 7import { GetUrlSubGraphUseCase } from '../../application/useCases/queries/GetUrlSubGraphUseCase'; 8import { DrizzleGraphQueryRepository } from '../../infrastructure/repositories/DrizzleGraphQueryRepository'; 9import { DrizzleCardRepository } from '../../infrastructure/repositories/DrizzleCardRepository'; 10import { DrizzleCollectionRepository } from '../../infrastructure/repositories/DrizzleCollectionRepository'; 11import { DrizzleConnectionRepository } from '../../infrastructure/repositories/DrizzleConnectionRepository'; 12import { CuratorId } from '../../domain/value-objects/CuratorId'; 13import { CardBuilder } from '../utils/builders/CardBuilder'; 14import { URL } from '../../domain/value-objects/URL'; 15import { CardTypeEnum } from '../../domain/value-objects/CardType'; 16import { Collection, CollectionAccessType } from '../../domain/Collection'; 17import { Connection } from '../../domain/Connection'; 18import { UrlOrCardId } from '../../domain/value-objects/UrlOrCardId'; 19import { createTestSchema } from '../test-utils/createTestSchema'; 20import { cards } from '../../infrastructure/repositories/schema/card.sql'; 21import { 22 collections as collectionsTable, 23 collectionCards, 24} from '../../infrastructure/repositories/schema/collection.sql'; 25import { connections as connectionsTable } from '../../infrastructure/repositories/schema/connection.sql'; 26import { libraryMemberships } from '../../infrastructure/repositories/schema/libraryMembership.sql'; 27import { publishedRecords } from '../../infrastructure/repositories/schema/publishedRecord.sql'; 28 29describe('GetUrlSubGraphUseCase - Depth Traversal', () => { 30 let container: StartedPostgreSqlContainer; 31 let db: PostgresJsDatabase; 32 let useCase: GetUrlSubGraphUseCase; 33 let cardRepository: DrizzleCardRepository; 34 let collectionRepository: DrizzleCollectionRepository; 35 let connectionRepository: DrizzleConnectionRepository; 36 let graphQueryRepository: DrizzleGraphQueryRepository; 37 38 let curator: CuratorId; 39 40 beforeAll(async () => { 41 // Start PostgreSQL container 42 container = await new PostgreSqlContainer('postgres:14').start(); 43 44 // Create database connection 45 const connectionString = container.getConnectionUri(); 46 process.env.DATABASE_URL = connectionString; 47 const client = postgres(connectionString); 48 db = drizzle(client); 49 50 // Create repositories 51 cardRepository = new DrizzleCardRepository(db); 52 collectionRepository = new DrizzleCollectionRepository(db); 53 connectionRepository = new DrizzleConnectionRepository(db); 54 graphQueryRepository = new DrizzleGraphQueryRepository(db); 55 56 // Create use case 57 useCase = new GetUrlSubGraphUseCase(graphQueryRepository); 58 59 // Create schema 60 await createTestSchema(db); 61 62 curator = CuratorId.create('did:plc:testcurator').unwrap(); 63 }, 60000); 64 65 afterAll(async () => { 66 await container.stop(); 67 }); 68 69 beforeEach(async () => { 70 // Clear all data 71 await db.delete(collectionCards); 72 await db.delete(connectionsTable); 73 await db.delete(collectionsTable); 74 await db.delete(libraryMemberships); 75 await db.delete(cards); 76 await db.delete(publishedRecords); 77 }); 78 79 describe('Depth 1 traversal', () => { 80 it('should return collections containing the URL and directly connected URLs', async () => { 81 // Create URL-A (target) 82 const urlA = 'https://example.com/url-a'; 83 const cardA = new CardBuilder() 84 .withCuratorId(curator.value) 85 .withType(CardTypeEnum.URL) 86 .withUrl(URL.create(urlA).unwrap()) 87 .buildOrThrow(); 88 await cardRepository.save(cardA); 89 90 // Create URL-B (connected via connection) 91 const urlB = 'https://example.com/url-b'; 92 const cardB = new CardBuilder() 93 .withCuratorId(curator.value) 94 .withType(CardTypeEnum.URL) 95 .withUrl(URL.create(urlB).unwrap()) 96 .buildOrThrow(); 97 await cardRepository.save(cardB); 98 99 // Create URL-C (in same collection as URL-A) 100 const urlC = 'https://example.com/url-c'; 101 const cardC = new CardBuilder() 102 .withCuratorId(curator.value) 103 .withType(CardTypeEnum.URL) 104 .withUrl(URL.create(urlC).unwrap()) 105 .buildOrThrow(); 106 await cardRepository.save(cardC); 107 108 // Create COLLECTION-1 containing URL-A and URL-C 109 const collection1 = Collection.create({ 110 authorId: curator, 111 name: 'Collection 1', 112 accessType: CollectionAccessType.OPEN, 113 collaboratorIds: [], 114 createdAt: new Date(), 115 updatedAt: new Date(), 116 }).unwrap(); 117 await collectionRepository.save(collection1); 118 collection1.addCard(cardA.cardId, curator); 119 collection1.addCard(cardC.cardId, curator); 120 await collectionRepository.save(collection1); 121 122 // Create CONNECTION from URL-A to URL-B 123 const connection = Connection.create({ 124 source: UrlOrCardId.createFromUrl(URL.create(urlA).unwrap()).unwrap(), 125 target: UrlOrCardId.createFromUrl(URL.create(urlB).unwrap()).unwrap(), 126 curatorId: curator, 127 }).unwrap(); 128 await connectionRepository.save(connection); 129 130 // Execute query with depth 1 131 const result = await useCase.execute({ url: urlA, depth: 1 }); 132 133 expect(result.isOk()).toBe(true); 134 const graphData = result.unwrap(); 135 136 // Should have 3 nodes: URL-A, URL-B (connected), COLLECTION-1 137 // URL-C is NOT included at depth 1 (it's in the collection but we don't expand from collections yet) 138 expect(graphData.nodes.length).toBeGreaterThanOrEqual(3); 139 140 const nodeIds = graphData.nodes.map((n) => n.id); 141 expect(nodeIds).toContain(`url:${urlA}`); 142 expect(nodeIds).toContain(`url:${urlB}`); 143 expect(nodeIds).toContain( 144 `collection:${collection1.collectionId.getStringValue()}`, 145 ); 146 147 // Should have 2 edges: URL-A → URL-B (connection), COLLECTION-1 → URL-A (contains) 148 expect(graphData.edges.length).toBeGreaterThanOrEqual(2); 149 150 const edgeTypes = graphData.edges.map((e) => e.type); 151 expect(edgeTypes).toContain('URL_CONNECTS_URL'); 152 expect(edgeTypes).toContain('COLLECTION_CONTAINS_URL'); 153 }); 154 }); 155 156 describe('Depth 2 traversal', () => { 157 it('should expand from collections to include all URLs in those collections', async () => { 158 // Create URL-A (target) 159 const urlA = 'https://example.com/url-a'; 160 const cardA = new CardBuilder() 161 .withCuratorId(curator.value) 162 .withType(CardTypeEnum.URL) 163 .withUrl(URL.create(urlA).unwrap()) 164 .buildOrThrow(); 165 await cardRepository.save(cardA); 166 167 // Create URL-B and URL-C (in same collection as URL-A) 168 const urlB = 'https://example.com/url-b'; 169 const cardB = new CardBuilder() 170 .withCuratorId(curator.value) 171 .withType(CardTypeEnum.URL) 172 .withUrl(URL.create(urlB).unwrap()) 173 .buildOrThrow(); 174 await cardRepository.save(cardB); 175 176 const urlC = 'https://example.com/url-c'; 177 const cardC = new CardBuilder() 178 .withCuratorId(curator.value) 179 .withType(CardTypeEnum.URL) 180 .withUrl(URL.create(urlC).unwrap()) 181 .buildOrThrow(); 182 await cardRepository.save(cardC); 183 184 // Create COLLECTION-1 containing URL-A, URL-B, and URL-C 185 const collection1 = Collection.create({ 186 authorId: curator, 187 name: 'Collection 1', 188 accessType: CollectionAccessType.OPEN, 189 collaboratorIds: [], 190 createdAt: new Date(), 191 updatedAt: new Date(), 192 }).unwrap(); 193 await collectionRepository.save(collection1); 194 collection1.addCard(cardA.cardId, curator); 195 collection1.addCard(cardB.cardId, curator); 196 collection1.addCard(cardC.cardId, curator); 197 await collectionRepository.save(collection1); 198 199 // Execute query with depth 1 - should NOT include URL-B and URL-C 200 const depth1Result = await useCase.execute({ url: urlA, depth: 1 }); 201 expect(depth1Result.isOk()).toBe(true); 202 const depth1Data = depth1Result.unwrap(); 203 204 const depth1NodeIds = depth1Data.nodes.map((n) => n.id); 205 // At depth 1, we should see URL-A and COLLECTION-1, but not URL-B or URL-C yet 206 expect(depth1NodeIds).toContain(`url:${urlA}`); 207 expect(depth1NodeIds).toContain( 208 `collection:${collection1.collectionId.getStringValue()}`, 209 ); 210 211 // Execute query with depth 2 - should include URL-B and URL-C 212 const depth2Result = await useCase.execute({ url: urlA, depth: 2 }); 213 expect(depth2Result.isOk()).toBe(true); 214 const depth2Data = depth2Result.unwrap(); 215 216 const depth2NodeIds = depth2Data.nodes.map((n) => n.id); 217 // At depth 2, we should expand from COLLECTION-1 and see URL-B and URL-C 218 expect(depth2NodeIds).toContain(`url:${urlA}`); 219 expect(depth2NodeIds).toContain(`url:${urlB}`); 220 expect(depth2NodeIds).toContain(`url:${urlC}`); 221 expect(depth2NodeIds).toContain( 222 `collection:${collection1.collectionId.getStringValue()}`, 223 ); 224 225 // Should have edges from collection to all its URLs 226 const collectionEdges = depth2Data.edges.filter( 227 (e) => 228 e.type === 'COLLECTION_CONTAINS_URL' && 229 e.source === 230 `collection:${collection1.collectionId.getStringValue()}`, 231 ); 232 expect(collectionEdges.length).toBeGreaterThanOrEqual(3); // Collection → URL-A, URL-B, URL-C 233 }); 234 235 it('should expand from connected URLs to get their collections', async () => { 236 // Create URL-A (target) 237 const urlA = 'https://example.com/url-a'; 238 const cardA = new CardBuilder() 239 .withCuratorId(curator.value) 240 .withType(CardTypeEnum.URL) 241 .withUrl(URL.create(urlA).unwrap()) 242 .buildOrThrow(); 243 await cardRepository.save(cardA); 244 245 // Create URL-B (connected to URL-A) 246 const urlB = 'https://example.com/url-b'; 247 const cardB = new CardBuilder() 248 .withCuratorId(curator.value) 249 .withType(CardTypeEnum.URL) 250 .withUrl(URL.create(urlB).unwrap()) 251 .buildOrThrow(); 252 await cardRepository.save(cardB); 253 254 // Create COLLECTION-2 containing URL-B 255 const collection2 = Collection.create({ 256 authorId: curator, 257 name: 'Collection 2', 258 accessType: CollectionAccessType.OPEN, 259 collaboratorIds: [], 260 createdAt: new Date(), 261 updatedAt: new Date(), 262 }).unwrap(); 263 await collectionRepository.save(collection2); 264 collection2.addCard(cardB.cardId, curator); 265 await collectionRepository.save(collection2); 266 267 // Create CONNECTION from URL-A to URL-B 268 const connection = Connection.create({ 269 source: UrlOrCardId.createFromUrl(URL.create(urlA).unwrap()).unwrap(), 270 target: UrlOrCardId.createFromUrl(URL.create(urlB).unwrap()).unwrap(), 271 curatorId: curator, 272 }).unwrap(); 273 await connectionRepository.save(connection); 274 275 // Execute query with depth 2 276 const result = await useCase.execute({ url: urlA, depth: 2 }); 277 278 expect(result.isOk()).toBe(true); 279 const graphData = result.unwrap(); 280 281 const nodeIds = graphData.nodes.map((n) => n.id); 282 // Should include URL-A, URL-B (depth 1), and COLLECTION-2 (depth 2, containing URL-B) 283 expect(nodeIds).toContain(`url:${urlA}`); 284 expect(nodeIds).toContain(`url:${urlB}`); 285 expect(nodeIds).toContain( 286 `collection:${collection2.collectionId.getStringValue()}`, 287 ); 288 }); 289 }); 290 291 describe('Depth 3 traversal', () => { 292 it('should recursively expand the graph to depth 3', async () => { 293 // Create a chain: URL-A → COLLECTION-1 → URL-B → COLLECTION-2 → URL-C → CONNECTION → URL-D 294 295 const urlA = 'https://example.com/url-a'; 296 const cardA = new CardBuilder() 297 .withCuratorId(curator.value) 298 .withType(CardTypeEnum.URL) 299 .withUrl(URL.create(urlA).unwrap()) 300 .buildOrThrow(); 301 await cardRepository.save(cardA); 302 303 const urlB = 'https://example.com/url-b'; 304 const cardB = new CardBuilder() 305 .withCuratorId(curator.value) 306 .withType(CardTypeEnum.URL) 307 .withUrl(URL.create(urlB).unwrap()) 308 .buildOrThrow(); 309 await cardRepository.save(cardB); 310 311 const urlC = 'https://example.com/url-c'; 312 const cardC = new CardBuilder() 313 .withCuratorId(curator.value) 314 .withType(CardTypeEnum.URL) 315 .withUrl(URL.create(urlC).unwrap()) 316 .buildOrThrow(); 317 await cardRepository.save(cardC); 318 319 const urlD = 'https://example.com/url-d'; 320 const cardD = new CardBuilder() 321 .withCuratorId(curator.value) 322 .withType(CardTypeEnum.URL) 323 .withUrl(URL.create(urlD).unwrap()) 324 .buildOrThrow(); 325 await cardRepository.save(cardD); 326 327 // COLLECTION-1 contains URL-A and URL-B 328 const collection1 = Collection.create({ 329 authorId: curator, 330 name: 'Collection 1', 331 accessType: CollectionAccessType.OPEN, 332 collaboratorIds: [], 333 createdAt: new Date(), 334 updatedAt: new Date(), 335 }).unwrap(); 336 await collectionRepository.save(collection1); 337 collection1.addCard(cardA.cardId, curator); 338 collection1.addCard(cardB.cardId, curator); 339 await collectionRepository.save(collection1); 340 341 // COLLECTION-2 contains URL-B and URL-C 342 const collection2 = Collection.create({ 343 authorId: curator, 344 name: 'Collection 2', 345 accessType: CollectionAccessType.OPEN, 346 collaboratorIds: [], 347 createdAt: new Date(), 348 updatedAt: new Date(), 349 }).unwrap(); 350 await collectionRepository.save(collection2); 351 collection2.addCard(cardB.cardId, curator); 352 collection2.addCard(cardC.cardId, curator); 353 await collectionRepository.save(collection2); 354 355 // CONNECTION from URL-C to URL-D 356 const connection = Connection.create({ 357 source: UrlOrCardId.createFromUrl(URL.create(urlC).unwrap()).unwrap(), 358 target: UrlOrCardId.createFromUrl(URL.create(urlD).unwrap()).unwrap(), 359 curatorId: curator, 360 }).unwrap(); 361 await connectionRepository.save(connection); 362 363 // Test depth 2 - should NOT include URL-D 364 const depth2Result = await useCase.execute({ url: urlA, depth: 2 }); 365 expect(depth2Result.isOk()).toBe(true); 366 const depth2Data = depth2Result.unwrap(); 367 const depth2NodeIds = depth2Data.nodes.map((n) => n.id); 368 369 // Should see: URL-A (depth 0), COLLECTION-1 (depth 1), URL-B (depth 2), COLLECTION-2 (depth 2) 370 expect(depth2NodeIds).toContain(`url:${urlA}`); 371 expect(depth2NodeIds).toContain(`url:${urlB}`); 372 expect(depth2NodeIds).toContain( 373 `collection:${collection1.collectionId.getStringValue()}`, 374 ); 375 376 // Test depth 3 - should include URL-C and URL-D 377 const depth3Result = await useCase.execute({ url: urlA, depth: 3 }); 378 expect(depth3Result.isOk()).toBe(true); 379 const depth3Data = depth3Result.unwrap(); 380 const depth3NodeIds = depth3Data.nodes.map((n) => n.id); 381 382 // Should see all nodes including URL-C (depth 3 from COLLECTION-2) and URL-D (depth 3 from connection) 383 expect(depth3NodeIds).toContain(`url:${urlA}`); 384 expect(depth3NodeIds).toContain(`url:${urlB}`); 385 expect(depth3NodeIds).toContain(`url:${urlC}`); 386 expect(depth3NodeIds).toContain(`url:${urlD}`); 387 expect(depth3NodeIds).toContain( 388 `collection:${collection1.collectionId.getStringValue()}`, 389 ); 390 expect(depth3NodeIds).toContain( 391 `collection:${collection2.collectionId.getStringValue()}`, 392 ); 393 }); 394 }); 395 396 describe('Shortest path behavior', () => { 397 it('should show nodes at their earliest (shortest path) depth', async () => { 398 // Create a diamond pattern: 399 // URL-A → CONNECTION → URL-B 400 // URL-A → COLLECTION-1 → URL-C → CONNECTION → URL-B 401 // URL-B should appear at depth 1 (via direct connection), not depth 3 (via collection path) 402 403 const urlA = 'https://example.com/url-a'; 404 const cardA = new CardBuilder() 405 .withCuratorId(curator.value) 406 .withType(CardTypeEnum.URL) 407 .withUrl(URL.create(urlA).unwrap()) 408 .buildOrThrow(); 409 await cardRepository.save(cardA); 410 411 const urlB = 'https://example.com/url-b'; 412 const cardB = new CardBuilder() 413 .withCuratorId(curator.value) 414 .withType(CardTypeEnum.URL) 415 .withUrl(URL.create(urlB).unwrap()) 416 .buildOrThrow(); 417 await cardRepository.save(cardB); 418 419 const urlC = 'https://example.com/url-c'; 420 const cardC = new CardBuilder() 421 .withCuratorId(curator.value) 422 .withType(CardTypeEnum.URL) 423 .withUrl(URL.create(urlC).unwrap()) 424 .buildOrThrow(); 425 await cardRepository.save(cardC); 426 427 // Direct connection: URL-A → URL-B 428 const connectionAB = Connection.create({ 429 source: UrlOrCardId.createFromUrl(URL.create(urlA).unwrap()).unwrap(), 430 target: UrlOrCardId.createFromUrl(URL.create(urlB).unwrap()).unwrap(), 431 curatorId: curator, 432 }).unwrap(); 433 await connectionRepository.save(connectionAB); 434 435 // Collection path: URL-A → COLLECTION-1 → URL-C → URL-B 436 const collection1 = Collection.create({ 437 authorId: curator, 438 name: 'Collection 1', 439 accessType: CollectionAccessType.OPEN, 440 collaboratorIds: [], 441 createdAt: new Date(), 442 updatedAt: new Date(), 443 }).unwrap(); 444 await collectionRepository.save(collection1); 445 collection1.addCard(cardA.cardId, curator); 446 collection1.addCard(cardC.cardId, curator); 447 await collectionRepository.save(collection1); 448 449 const connectionCB = Connection.create({ 450 source: UrlOrCardId.createFromUrl(URL.create(urlC).unwrap()).unwrap(), 451 target: UrlOrCardId.createFromUrl(URL.create(urlB).unwrap()).unwrap(), 452 curatorId: curator, 453 }).unwrap(); 454 await connectionRepository.save(connectionCB); 455 456 // At depth 1, URL-B should be discovered via direct connection 457 const depth1Result = await useCase.execute({ url: urlA, depth: 1 }); 458 expect(depth1Result.isOk()).toBe(true); 459 const depth1Data = depth1Result.unwrap(); 460 const depth1NodeIds = depth1Data.nodes.map((n) => n.id); 461 462 expect(depth1NodeIds).toContain(`url:${urlB}`); // Should appear at depth 1 463 464 // At depth 3, URL-B should still only appear once (not duplicated) 465 const depth3Result = await useCase.execute({ url: urlA, depth: 3 }); 466 expect(depth3Result.isOk()).toBe(true); 467 const depth3Data = depth3Result.unwrap(); 468 469 // Count occurrences of URL-B 470 const urlBCount = depth3Data.nodes.filter( 471 (n) => n.id === `url:${urlB}`, 472 ).length; 473 expect(urlBCount).toBe(1); // Should only appear once 474 }); 475 }); 476 477 describe('Edge cases', () => { 478 it('should deduplicate nodes when multiple cards exist for the same URL', async () => { 479 // Create multiple URL cards for the same URL by different authors 480 const url = 'https://example.com/popular-article'; 481 const curator1 = CuratorId.create('did:plc:curator1').unwrap(); 482 const curator2 = CuratorId.create('did:plc:curator2').unwrap(); 483 484 const card1 = new CardBuilder() 485 .withCuratorId(curator1.value) 486 .withType(CardTypeEnum.URL) 487 .withUrl(URL.create(url).unwrap()) 488 .buildOrThrow(); 489 await cardRepository.save(card1); 490 491 const card2 = new CardBuilder() 492 .withCuratorId(curator2.value) 493 .withType(CardTypeEnum.URL) 494 .withUrl(URL.create(url).unwrap()) 495 .buildOrThrow(); 496 await cardRepository.save(card2); 497 498 const result = await useCase.execute({ url, depth: 1 }); 499 500 expect(result.isOk()).toBe(true); 501 const graphData = result.unwrap(); 502 503 // Should only have 1 node for the URL, not 2 504 const urlNodes = graphData.nodes.filter((n) => n.id === `url:${url}`); 505 expect(urlNodes.length).toBe(1); 506 }); 507 508 it('should handle URLs not in the database', async () => { 509 const nonExistentUrl = 'https://example.com/does-not-exist'; 510 511 const result = await useCase.execute({ url: nonExistentUrl, depth: 1 }); 512 513 expect(result.isOk()).toBe(true); 514 const graphData = result.unwrap(); 515 516 // Should return a synthetic node for the URL 517 expect(graphData.nodes.length).toBe(1); 518 expect(graphData.nodes[0]?.id).toBe(`url:${nonExistentUrl}`); 519 expect(graphData.nodes[0]?.metadata.synthetic).toBe(true); 520 expect(graphData.edges.length).toBe(0); 521 }); 522 523 it('should clamp depth to maximum of 5', async () => { 524 const url = 'https://example.com/url'; 525 const card = new CardBuilder() 526 .withCuratorId(curator.value) 527 .withType(CardTypeEnum.URL) 528 .withUrl(URL.create(url).unwrap()) 529 .buildOrThrow(); 530 await cardRepository.save(card); 531 532 const result = await useCase.execute({ url, depth: 100 }); 533 534 expect(result.isOk()).toBe(true); 535 // Should not error, depth should be clamped internally 536 }); 537 538 it('should clamp depth to minimum of 1', async () => { 539 const url = 'https://example.com/url'; 540 const card = new CardBuilder() 541 .withCuratorId(curator.value) 542 .withType(CardTypeEnum.URL) 543 .withUrl(URL.create(url).unwrap()) 544 .buildOrThrow(); 545 await cardRepository.save(card); 546 547 const result = await useCase.execute({ url, depth: 0 }); 548 549 expect(result.isOk()).toBe(true); 550 // Should return at least the target URL node 551 const graphData = result.unwrap(); 552 expect(graphData.nodes.length).toBeGreaterThanOrEqual(1); 553 }); 554 }); 555});