This repository has no description
0

Configure Feed

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

feat: implement getCollectionsWithUrl method in DrizzleCollectionQueryRepository with comprehensive test coverage

Co-authored-by: aider (anthropic/claude-sonnet-4-5-20250929) <aider@aider.chat>

+573 -4
+58 -1
src/modules/cards/infrastructure/repositories/DrizzleCollectionQueryRepository.ts
··· 1 - import { eq, desc, asc, count, sql, or, ilike, and } from 'drizzle-orm'; 1 + import { eq, desc, asc, count, sql, or, ilike, and, inArray } from 'drizzle-orm'; 2 2 import { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; 3 3 import { 4 4 ICollectionQueryRepository, ··· 8 8 CollectionSortField, 9 9 SortOrder, 10 10 CollectionContainingCardDTO, 11 + CollectionForUrlDTO, 11 12 } from '../../domain/ICollectionQueryRepository'; 12 13 import { collections, collectionCards } from './schema/collection.sql'; 13 14 import { publishedRecords } from './schema/publishedRecord.sql'; 15 + import { cards } from './schema/card.sql'; 14 16 import { CollectionMapper } from './mappers/CollectionMapper'; 17 + import { CardTypeEnum } from '../../domain/value-objects/CardType'; 15 18 16 19 export class DrizzleCollectionQueryRepository 17 20 implements ICollectionQueryRepository ··· 147 150 })); 148 151 } catch (error) { 149 152 console.error('Error in getCollectionsContainingCardForUser:', error); 153 + throw error; 154 + } 155 + } 156 + 157 + async getCollectionsWithUrl(url: string): Promise<CollectionForUrlDTO[]> { 158 + try { 159 + // Find all URL cards with this URL 160 + const urlCardsQuery = this.db 161 + .select({ 162 + id: cards.id, 163 + }) 164 + .from(cards) 165 + .where(and(eq(cards.url, url), eq(cards.type, CardTypeEnum.URL))); 166 + 167 + const urlCardsResult = await urlCardsQuery; 168 + 169 + if (urlCardsResult.length === 0) { 170 + return []; 171 + } 172 + 173 + const cardIds = urlCardsResult.map((card) => card.id); 174 + 175 + // Find all collections that contain any of these cards 176 + const collectionsQuery = this.db 177 + .selectDistinct({ 178 + id: collections.id, 179 + name: collections.name, 180 + description: collections.description, 181 + authorId: collections.authorId, 182 + uri: publishedRecords.uri, 183 + }) 184 + .from(collections) 185 + .leftJoin( 186 + publishedRecords, 187 + eq(collections.publishedRecordId, publishedRecords.id), 188 + ) 189 + .innerJoin( 190 + collectionCards, 191 + eq(collections.id, collectionCards.collectionId), 192 + ) 193 + .where(inArray(collectionCards.cardId, cardIds)) 194 + .orderBy(asc(collections.name)); 195 + 196 + const collectionsResult = await collectionsQuery; 197 + 198 + return collectionsResult.map((result) => ({ 199 + id: result.id, 200 + uri: result.uri || undefined, 201 + name: result.name, 202 + description: result.description || undefined, 203 + authorId: result.authorId, 204 + })); 205 + } catch (error) { 206 + console.error('Error in getCollectionsWithUrl:', error); 150 207 throw error; 151 208 } 152 209 }
+1 -1
src/modules/cards/infrastructure/repositories/schema/card.sql.ts
··· 41 41 table.type, 42 42 table.updatedAt.desc(), 43 43 ), 44 - // Index for getLibrariesForUrl - fast URL+type lookups 44 + // Index for getLibrariesForUrl and getCollectionsWithUrl - fast URL+type lookups 45 45 urlTypeIdx: index('idx_cards_url_type').on(table.url, table.type), 46 46 // Partial index for finding NOTE cards by parent - only indexes NOTE type cards 47 47 parentTypeIdx: index('idx_cards_parent_type')
+512
src/modules/cards/tests/infrastructure/DrizzleCollectionQueryRepository.getCollectionsWithUrl.integration.test.ts
··· 1 + import { 2 + PostgreSqlContainer, 3 + StartedPostgreSqlContainer, 4 + } from '@testcontainers/postgresql'; 5 + import postgres from 'postgres'; 6 + import { drizzle, PostgresJsDatabase } from 'drizzle-orm/postgres-js'; 7 + import { DrizzleCollectionQueryRepository } from '../../infrastructure/repositories/DrizzleCollectionQueryRepository'; 8 + import { DrizzleCardRepository } from '../../infrastructure/repositories/DrizzleCardRepository'; 9 + import { DrizzleCollectionRepository } from '../../infrastructure/repositories/DrizzleCollectionRepository'; 10 + import { CuratorId } from '../../domain/value-objects/CuratorId'; 11 + import { cards } from '../../infrastructure/repositories/schema/card.sql'; 12 + import { 13 + collections, 14 + collectionCards, 15 + } from '../../infrastructure/repositories/schema/collection.sql'; 16 + import { libraryMemberships } from '../../infrastructure/repositories/schema/libraryMembership.sql'; 17 + import { publishedRecords } from '../../infrastructure/repositories/schema/publishedRecord.sql'; 18 + import { CardBuilder } from '../utils/builders/CardBuilder'; 19 + import { CollectionBuilder } from '../utils/builders/CollectionBuilder'; 20 + import { URL } from '../../domain/value-objects/URL'; 21 + import { createTestSchema } from '../test-utils/createTestSchema'; 22 + import { CardTypeEnum } from '../../domain/value-objects/CardType'; 23 + import { PublishedRecordId } from '../../domain/value-objects/PublishedRecordId'; 24 + 25 + describe('DrizzleCollectionQueryRepository - getCollectionsWithUrl', () => { 26 + let container: StartedPostgreSqlContainer; 27 + let db: PostgresJsDatabase; 28 + let queryRepository: DrizzleCollectionQueryRepository; 29 + let cardRepository: DrizzleCardRepository; 30 + let collectionRepository: DrizzleCollectionRepository; 31 + 32 + // Test data 33 + let curator1: CuratorId; 34 + let curator2: CuratorId; 35 + let curator3: CuratorId; 36 + 37 + // Setup before all tests 38 + beforeAll(async () => { 39 + // Start PostgreSQL container 40 + container = await new PostgreSqlContainer('postgres:14').start(); 41 + 42 + // Create database connection 43 + const connectionString = container.getConnectionUri(); 44 + process.env.DATABASE_URL = connectionString; 45 + const client = postgres(connectionString); 46 + db = drizzle(client); 47 + 48 + // Create repositories 49 + queryRepository = new DrizzleCollectionQueryRepository(db); 50 + cardRepository = new DrizzleCardRepository(db); 51 + collectionRepository = new DrizzleCollectionRepository(db); 52 + 53 + // Create schema using helper function 54 + await createTestSchema(db); 55 + 56 + // Create test data 57 + curator1 = CuratorId.create('did:plc:curator1').unwrap(); 58 + curator2 = CuratorId.create('did:plc:curator2').unwrap(); 59 + curator3 = CuratorId.create('did:plc:curator3').unwrap(); 60 + }, 60000); // Increase timeout for container startup 61 + 62 + // Cleanup after all tests 63 + afterAll(async () => { 64 + // Stop container 65 + await container.stop(); 66 + }); 67 + 68 + // Clear data between tests 69 + beforeEach(async () => { 70 + await db.delete(collectionCards); 71 + await db.delete(collections); 72 + await db.delete(libraryMemberships); 73 + await db.delete(cards); 74 + await db.delete(publishedRecords); 75 + }); 76 + 77 + describe('Collections with URL cards', () => { 78 + it('should return all collections containing cards with the specified URL', async () => { 79 + const testUrl = 'https://example.com/shared-article'; 80 + const url = URL.create(testUrl).unwrap(); 81 + 82 + // Create URL cards for different users with the same URL 83 + const card1 = new CardBuilder() 84 + .withCuratorId(curator1.value) 85 + .withType(CardTypeEnum.URL) 86 + .withUrl(url) 87 + .buildOrThrow(); 88 + 89 + const card2 = new CardBuilder() 90 + .withCuratorId(curator2.value) 91 + .withType(CardTypeEnum.URL) 92 + .withUrl(url) 93 + .buildOrThrow(); 94 + 95 + const card3 = new CardBuilder() 96 + .withCuratorId(curator3.value) 97 + .withType(CardTypeEnum.URL) 98 + .withUrl(url) 99 + .buildOrThrow(); 100 + 101 + // Add cards to their respective libraries 102 + card1.addToLibrary(curator1); 103 + card2.addToLibrary(curator2); 104 + card3.addToLibrary(curator3); 105 + 106 + await cardRepository.save(card1); 107 + await cardRepository.save(card2); 108 + await cardRepository.save(card3); 109 + 110 + // Create collections for each user and add their cards 111 + const collection1 = new CollectionBuilder() 112 + .withAuthorId(curator1.value) 113 + .withName('Tech Articles') 114 + .withDescription('My tech articles') 115 + .buildOrThrow(); 116 + 117 + const collection2 = new CollectionBuilder() 118 + .withAuthorId(curator2.value) 119 + .withName('Reading List') 120 + .withDescription('Articles to read') 121 + .buildOrThrow(); 122 + 123 + const collection3 = new CollectionBuilder() 124 + .withAuthorId(curator3.value) 125 + .withName('Favorites') 126 + .buildOrThrow(); 127 + 128 + // Add cards to collections 129 + collection1.addCard(card1.cardId, curator1); 130 + collection2.addCard(card2.cardId, curator2); 131 + collection3.addCard(card3.cardId, curator3); 132 + 133 + // Mark collections as published 134 + const publishedRecordId1 = PublishedRecordId.create({ 135 + uri: 'at://did:plc:curator1/network.cosmik.collection/collection1', 136 + cid: 'bafyreicollection1', 137 + }); 138 + const publishedRecordId2 = PublishedRecordId.create({ 139 + uri: 'at://did:plc:curator2/network.cosmik.collection/collection2', 140 + cid: 'bafyreicollection2', 141 + }); 142 + const publishedRecordId3 = PublishedRecordId.create({ 143 + uri: 'at://did:plc:curator3/network.cosmik.collection/collection3', 144 + cid: 'bafyreicollection3', 145 + }); 146 + 147 + collection1.markAsPublished(publishedRecordId1); 148 + collection2.markAsPublished(publishedRecordId2); 149 + collection3.markAsPublished(publishedRecordId3); 150 + 151 + await collectionRepository.save(collection1); 152 + await collectionRepository.save(collection2); 153 + await collectionRepository.save(collection3); 154 + 155 + // Execute the query 156 + const result = await queryRepository.getCollectionsWithUrl(testUrl); 157 + 158 + // Verify the result 159 + expect(result).toHaveLength(3); 160 + 161 + // Check that all three collections are included 162 + const collectionIds = result.map((c) => c.id); 163 + expect(collectionIds).toContain( 164 + collection1.collectionId.getStringValue(), 165 + ); 166 + expect(collectionIds).toContain( 167 + collection2.collectionId.getStringValue(), 168 + ); 169 + expect(collectionIds).toContain( 170 + collection3.collectionId.getStringValue(), 171 + ); 172 + 173 + // Verify collection details 174 + const techArticles = result.find((c) => c.name === 'Tech Articles'); 175 + expect(techArticles).toBeDefined(); 176 + expect(techArticles?.description).toBe('My tech articles'); 177 + expect(techArticles?.authorId).toBe(curator1.value); 178 + expect(techArticles?.uri).toBe( 179 + 'at://did:plc:curator1/network.cosmik.collection/collection1', 180 + ); 181 + 182 + const readingList = result.find((c) => c.name === 'Reading List'); 183 + expect(readingList).toBeDefined(); 184 + expect(readingList?.description).toBe('Articles to read'); 185 + expect(readingList?.authorId).toBe(curator2.value); 186 + 187 + const favorites = result.find((c) => c.name === 'Favorites'); 188 + expect(favorites).toBeDefined(); 189 + expect(favorites?.description).toBeUndefined(); 190 + expect(favorites?.authorId).toBe(curator3.value); 191 + }); 192 + 193 + it('should return empty array when no collections contain cards with the specified URL', async () => { 194 + const testUrl = 'https://example.com/nonexistent-article'; 195 + 196 + const result = await queryRepository.getCollectionsWithUrl(testUrl); 197 + 198 + expect(result).toHaveLength(0); 199 + }); 200 + 201 + it('should not return collections that contain cards with different URLs', async () => { 202 + const testUrl1 = 'https://example.com/article1'; 203 + const testUrl2 = 'https://example.com/article2'; 204 + const url1 = URL.create(testUrl1).unwrap(); 205 + const url2 = URL.create(testUrl2).unwrap(); 206 + 207 + // Create cards with different URLs 208 + const card1 = new CardBuilder() 209 + .withCuratorId(curator1.value) 210 + .withType(CardTypeEnum.URL) 211 + .withUrl(url1) 212 + .buildOrThrow(); 213 + 214 + const card2 = new CardBuilder() 215 + .withCuratorId(curator2.value) 216 + .withType(CardTypeEnum.URL) 217 + .withUrl(url2) 218 + .buildOrThrow(); 219 + 220 + card1.addToLibrary(curator1); 221 + card2.addToLibrary(curator2); 222 + 223 + await cardRepository.save(card1); 224 + await cardRepository.save(card2); 225 + 226 + // Create collections 227 + const collection1 = new CollectionBuilder() 228 + .withAuthorId(curator1.value) 229 + .withName('Collection 1') 230 + .buildOrThrow(); 231 + 232 + const collection2 = new CollectionBuilder() 233 + .withAuthorId(curator2.value) 234 + .withName('Collection 2') 235 + .buildOrThrow(); 236 + 237 + collection1.addCard(card1.cardId, curator1); 238 + collection2.addCard(card2.cardId, curator2); 239 + 240 + await collectionRepository.save(collection1); 241 + await collectionRepository.save(collection2); 242 + 243 + // Query for testUrl1 244 + const result = await queryRepository.getCollectionsWithUrl(testUrl1); 245 + 246 + expect(result).toHaveLength(1); 247 + expect(result[0]!.name).toBe('Collection 1'); 248 + expect(result[0]!.authorId).toBe(curator1.value); 249 + }); 250 + 251 + it('should return multiple collections from the same user if they contain the URL', async () => { 252 + const testUrl = 'https://example.com/popular-article'; 253 + const url = URL.create(testUrl).unwrap(); 254 + 255 + // Create URL card 256 + const card = new CardBuilder() 257 + .withCuratorId(curator1.value) 258 + .withType(CardTypeEnum.URL) 259 + .withUrl(url) 260 + .buildOrThrow(); 261 + 262 + card.addToLibrary(curator1); 263 + await cardRepository.save(card); 264 + 265 + // Create multiple collections for the same user 266 + const collection1 = new CollectionBuilder() 267 + .withAuthorId(curator1.value) 268 + .withName('Tech') 269 + .buildOrThrow(); 270 + 271 + const collection2 = new CollectionBuilder() 272 + .withAuthorId(curator1.value) 273 + .withName('Favorites') 274 + .buildOrThrow(); 275 + 276 + const collection3 = new CollectionBuilder() 277 + .withAuthorId(curator1.value) 278 + .withName('To Read') 279 + .buildOrThrow(); 280 + 281 + // Add the same card to all collections 282 + collection1.addCard(card.cardId, curator1); 283 + collection2.addCard(card.cardId, curator1); 284 + collection3.addCard(card.cardId, curator1); 285 + 286 + await collectionRepository.save(collection1); 287 + await collectionRepository.save(collection2); 288 + await collectionRepository.save(collection3); 289 + 290 + // Execute the query 291 + const result = await queryRepository.getCollectionsWithUrl(testUrl); 292 + 293 + expect(result).toHaveLength(3); 294 + 295 + const collectionNames = result.map((c) => c.name); 296 + expect(collectionNames).toContain('Tech'); 297 + expect(collectionNames).toContain('Favorites'); 298 + expect(collectionNames).toContain('To Read'); 299 + 300 + // All should have the same author 301 + result.forEach((collection) => { 302 + expect(collection.authorId).toBe(curator1.value); 303 + }); 304 + }); 305 + 306 + it('should handle collections without published record IDs', async () => { 307 + const testUrl = 'https://example.com/unpublished-article'; 308 + const url = URL.create(testUrl).unwrap(); 309 + 310 + // Create URL card 311 + const card = new CardBuilder() 312 + .withCuratorId(curator1.value) 313 + .withType(CardTypeEnum.URL) 314 + .withUrl(url) 315 + .buildOrThrow(); 316 + 317 + card.addToLibrary(curator1); 318 + await cardRepository.save(card); 319 + 320 + // Create collection without publishing it 321 + const collection = new CollectionBuilder() 322 + .withAuthorId(curator1.value) 323 + .withName('Unpublished Collection') 324 + .buildOrThrow(); 325 + 326 + collection.addCard(card.cardId, curator1); 327 + await collectionRepository.save(collection); 328 + 329 + // Execute the query 330 + const result = await queryRepository.getCollectionsWithUrl(testUrl); 331 + 332 + expect(result).toHaveLength(1); 333 + expect(result[0]!.name).toBe('Unpublished Collection'); 334 + expect(result[0]!.uri).toBeUndefined(); 335 + }); 336 + 337 + it('should handle multiple cards with same URL from different users in same collection', async () => { 338 + const testUrl = 'https://example.com/shared-article'; 339 + const url = URL.create(testUrl).unwrap(); 340 + 341 + // Create URL cards for different users with the same URL 342 + const card1 = new CardBuilder() 343 + .withCuratorId(curator1.value) 344 + .withType(CardTypeEnum.URL) 345 + .withUrl(url) 346 + .buildOrThrow(); 347 + 348 + const card2 = new CardBuilder() 349 + .withCuratorId(curator2.value) 350 + .withType(CardTypeEnum.URL) 351 + .withUrl(url) 352 + .buildOrThrow(); 353 + 354 + card1.addToLibrary(curator1); 355 + card2.addToLibrary(curator2); 356 + 357 + await cardRepository.save(card1); 358 + await cardRepository.save(card2); 359 + 360 + // Create one collection that contains both cards 361 + const collection = new CollectionBuilder() 362 + .withAuthorId(curator1.value) 363 + .withName('Shared Collection') 364 + .buildOrThrow(); 365 + 366 + collection.addCard(card1.cardId, curator1); 367 + collection.addCard(card2.cardId, curator1); 368 + 369 + await collectionRepository.save(collection); 370 + 371 + // Execute the query 372 + const result = await queryRepository.getCollectionsWithUrl(testUrl); 373 + 374 + // Should return the collection only once, even though it has multiple cards with the URL 375 + expect(result).toHaveLength(1); 376 + expect(result[0]!.name).toBe('Shared Collection'); 377 + expect(result[0]!.authorId).toBe(curator1.value); 378 + }); 379 + 380 + it('should not return collections containing NOTE cards with the URL', async () => { 381 + const testUrl = 'https://example.com/article'; 382 + const url = URL.create(testUrl).unwrap(); 383 + 384 + // Create URL card 385 + const urlCard = new CardBuilder() 386 + .withCuratorId(curator1.value) 387 + .withType(CardTypeEnum.URL) 388 + .withUrl(url) 389 + .buildOrThrow(); 390 + 391 + // Create NOTE card with same URL (edge case) 392 + const noteCard = new CardBuilder() 393 + .withCuratorId(curator2.value) 394 + .withType(CardTypeEnum.NOTE) 395 + .withUrl(url) 396 + .buildOrThrow(); 397 + 398 + urlCard.addToLibrary(curator1); 399 + noteCard.addToLibrary(curator2); 400 + 401 + await cardRepository.save(urlCard); 402 + await cardRepository.save(noteCard); 403 + 404 + // Create collections 405 + const collection1 = new CollectionBuilder() 406 + .withAuthorId(curator1.value) 407 + .withName('URL Collection') 408 + .buildOrThrow(); 409 + 410 + const collection2 = new CollectionBuilder() 411 + .withAuthorId(curator2.value) 412 + .withName('Note Collection') 413 + .buildOrThrow(); 414 + 415 + collection1.addCard(urlCard.cardId, curator1); 416 + collection2.addCard(noteCard.cardId, curator2); 417 + 418 + await collectionRepository.save(collection1); 419 + await collectionRepository.save(collection2); 420 + 421 + const result = await queryRepository.getCollectionsWithUrl(testUrl); 422 + 423 + // Should only return the collection with the URL card, not the NOTE card 424 + expect(result).toHaveLength(1); 425 + expect(result[0]!.name).toBe('URL Collection'); 426 + expect(result[0]!.authorId).toBe(curator1.value); 427 + }); 428 + 429 + it('should handle cards not in any collection', async () => { 430 + const testUrl = 'https://example.com/article'; 431 + const url = URL.create(testUrl).unwrap(); 432 + 433 + // Create URL card but don't add to any collection 434 + const card = new CardBuilder() 435 + .withCuratorId(curator1.value) 436 + .withType(CardTypeEnum.URL) 437 + .withUrl(url) 438 + .buildOrThrow(); 439 + 440 + card.addToLibrary(curator1); 441 + await cardRepository.save(card); 442 + 443 + const result = await queryRepository.getCollectionsWithUrl(testUrl); 444 + 445 + // Should return empty since card is not in any collection 446 + expect(result).toHaveLength(0); 447 + }); 448 + 449 + it('should return collections sorted alphabetically by name', async () => { 450 + const testUrl = 'https://example.com/article'; 451 + const url = URL.create(testUrl).unwrap(); 452 + 453 + // Create URL cards 454 + const card1 = new CardBuilder() 455 + .withCuratorId(curator1.value) 456 + .withType(CardTypeEnum.URL) 457 + .withUrl(url) 458 + .buildOrThrow(); 459 + 460 + const card2 = new CardBuilder() 461 + .withCuratorId(curator2.value) 462 + .withType(CardTypeEnum.URL) 463 + .withUrl(url) 464 + .buildOrThrow(); 465 + 466 + const card3 = new CardBuilder() 467 + .withCuratorId(curator3.value) 468 + .withType(CardTypeEnum.URL) 469 + .withUrl(url) 470 + .buildOrThrow(); 471 + 472 + card1.addToLibrary(curator1); 473 + card2.addToLibrary(curator2); 474 + card3.addToLibrary(curator3); 475 + 476 + await cardRepository.save(card1); 477 + await cardRepository.save(card2); 478 + await cardRepository.save(card3); 479 + 480 + // Create collections with names that should be sorted 481 + const collectionZ = new CollectionBuilder() 482 + .withAuthorId(curator1.value) 483 + .withName('Zebra Collection') 484 + .buildOrThrow(); 485 + 486 + const collectionA = new CollectionBuilder() 487 + .withAuthorId(curator2.value) 488 + .withName('Apple Collection') 489 + .buildOrThrow(); 490 + 491 + const collectionM = new CollectionBuilder() 492 + .withAuthorId(curator3.value) 493 + .withName('Mango Collection') 494 + .buildOrThrow(); 495 + 496 + collectionZ.addCard(card1.cardId, curator1); 497 + collectionA.addCard(card2.cardId, curator2); 498 + collectionM.addCard(card3.cardId, curator3); 499 + 500 + await collectionRepository.save(collectionZ); 501 + await collectionRepository.save(collectionA); 502 + await collectionRepository.save(collectionM); 503 + 504 + const result = await queryRepository.getCollectionsWithUrl(testUrl); 505 + 506 + expect(result).toHaveLength(3); 507 + expect(result[0]!.name).toBe('Apple Collection'); 508 + expect(result[1]!.name).toBe('Mango Collection'); 509 + expect(result[2]!.name).toBe('Zebra Collection'); 510 + }); 511 + }); 512 + });
+2 -2
src/modules/cards/tests/test-utils/createTestSchema.ts
··· 107 107 ON cards(type, updated_at DESC) 108 108 `); 109 109 110 - // Covering index for getLibrariesForUrl - fast URL+type lookups with card ID included 110 + // Index for getLibrariesForUrl and getCollectionsWithUrl - fast URL+type lookups with card ID included 111 111 await db.execute(sql` 112 112 CREATE INDEX IF NOT EXISTS idx_cards_url_type 113 113 ON cards(url, type) INCLUDE (id) ··· 139 139 CREATE INDEX IF NOT EXISTS idx_feed_activities_actor_id ON feed_activities(actor_id); 140 140 `); 141 141 142 - // Index for efficient AT URI lookups 142 + // Index for efficient AT URI look ups 143 143 await db.execute(sql` 144 144 CREATE INDEX IF NOT EXISTS published_records_uri_idx ON published_records(uri); 145 145 `);