This repository has no description
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});