A social knowledge tool for researchers built on ATProto
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 { CardSortField, SortOrder } from '../../domain/ICardQueryRepository'; 16import { createTestSchema } from '../test-utils/createTestSchema'; 17import { CardTypeEnum } from '../../domain/value-objects/CardType'; 18 19describe('DrizzleCardQueryRepository - getLibrariesForUrl', () => { 20 let container: StartedPostgreSqlContainer; 21 let db: PostgresJsDatabase; 22 let queryRepository: DrizzleCardQueryRepository; 23 let cardRepository: DrizzleCardRepository; 24 25 // Test data 26 let curator1: CuratorId; 27 let curator2: CuratorId; 28 let curator3: CuratorId; 29 30 // Setup before all tests 31 beforeAll(async () => { 32 // Start PostgreSQL container 33 container = await new PostgreSqlContainer('postgres:14').start(); 34 35 // Create database connection 36 const connectionString = container.getConnectionUri(); 37 process.env.DATABASE_URL = connectionString; 38 const client = postgres(connectionString); 39 db = drizzle(client); 40 41 // Create repositories 42 queryRepository = new DrizzleCardQueryRepository(db); 43 cardRepository = new DrizzleCardRepository(db); 44 45 // Create schema using helper function 46 await createTestSchema(db); 47 48 // Create test data 49 curator1 = CuratorId.create('did:plc:curator1').unwrap(); 50 curator2 = CuratorId.create('did:plc:curator2').unwrap(); 51 curator3 = CuratorId.create('did:plc:curator3').unwrap(); 52 }, 60000); // Increase timeout for container startup 53 54 // Cleanup after all tests 55 afterAll(async () => { 56 // Stop container 57 await container.stop(); 58 }); 59 60 // Clear data between tests 61 beforeEach(async () => { 62 await db.delete(libraryMemberships); 63 await db.delete(cards); 64 await db.delete(publishedRecords); 65 }); 66 67 describe('getLibrariesForUrl', () => { 68 it('should return all users who have cards with the specified URL', async () => { 69 const testUrl = 'https://example.com/shared-article'; 70 const url = URL.create(testUrl).unwrap(); 71 72 // Create URL cards for different users with the same URL 73 const card1 = new CardBuilder() 74 .withCuratorId(curator1.value) 75 .withType(CardTypeEnum.URL) 76 .withUrl(url) 77 .buildOrThrow(); 78 79 const card2 = new CardBuilder() 80 .withCuratorId(curator2.value) 81 .withType(CardTypeEnum.URL) 82 .withUrl(url) 83 .buildOrThrow(); 84 85 const card3 = new CardBuilder() 86 .withCuratorId(curator3.value) 87 .withType(CardTypeEnum.URL) 88 .withUrl(url) 89 .buildOrThrow(); 90 91 // Add cards to their respective libraries 92 card1.addToLibrary(curator1); 93 card2.addToLibrary(curator2); 94 card3.addToLibrary(curator3); 95 96 // Save cards 97 await cardRepository.save(card1); 98 await cardRepository.save(card2); 99 await cardRepository.save(card3); 100 101 // Execute the query 102 const result = await queryRepository.getLibrariesForUrl(testUrl, { 103 page: 1, 104 limit: 10, 105 sortBy: CardSortField.UPDATED_AT, 106 sortOrder: SortOrder.DESC, 107 }); 108 109 // Verify the result 110 expect(result.items).toHaveLength(3); 111 expect(result.totalCount).toBe(3); 112 expect(result.hasMore).toBe(false); 113 114 // Check that all three users are included 115 const userIds = result.items.map((lib) => lib.userId); 116 expect(userIds).toContain(curator1.value); 117 expect(userIds).toContain(curator2.value); 118 expect(userIds).toContain(curator3.value); 119 120 // Check that card IDs are correct 121 const cardIds = result.items.map((lib) => lib.cardId); 122 expect(cardIds).toContain(card1.cardId.getStringValue()); 123 expect(cardIds).toContain(card2.cardId.getStringValue()); 124 expect(cardIds).toContain(card3.cardId.getStringValue()); 125 }); 126 127 it('should return empty result when no users have cards with the specified URL', async () => { 128 const testUrl = 'https://example.com/nonexistent-article'; 129 130 const result = await queryRepository.getLibrariesForUrl(testUrl, { 131 page: 1, 132 limit: 10, 133 sortBy: CardSortField.UPDATED_AT, 134 sortOrder: SortOrder.DESC, 135 }); 136 137 expect(result.items).toHaveLength(0); 138 expect(result.totalCount).toBe(0); 139 expect(result.hasMore).toBe(false); 140 }); 141 142 it('should not return users who have different URLs', async () => { 143 const testUrl1 = 'https://example.com/article1'; 144 const testUrl2 = 'https://example.com/article2'; 145 const url1 = URL.create(testUrl1).unwrap(); 146 const url2 = URL.create(testUrl2).unwrap(); 147 148 // Create cards with different URLs 149 const card1 = new CardBuilder() 150 .withCuratorId(curator1.value) 151 .withType(CardTypeEnum.URL) 152 .withUrl(url1) 153 .buildOrThrow(); 154 155 const card2 = new CardBuilder() 156 .withCuratorId(curator2.value) 157 .withType(CardTypeEnum.URL) 158 .withUrl(url2) 159 .buildOrThrow(); 160 161 card1.addToLibrary(curator1); 162 card2.addToLibrary(curator2); 163 164 await cardRepository.save(card1); 165 await cardRepository.save(card2); 166 167 // Query for testUrl1 168 const result = await queryRepository.getLibrariesForUrl(testUrl1, { 169 page: 1, 170 limit: 10, 171 sortBy: CardSortField.UPDATED_AT, 172 sortOrder: SortOrder.DESC, 173 }); 174 175 expect(result.items).toHaveLength(1); 176 expect(result.items[0]!.userId).toBe(curator1.value); 177 expect(result.items[0]!.cardId).toBe(card1.cardId.getStringValue()); 178 }); 179 180 it('should not return NOTE cards even if they have the same URL', async () => { 181 const testUrl = 'https://example.com/article'; 182 const url = URL.create(testUrl).unwrap(); 183 184 // Create URL card 185 const urlCard = new CardBuilder() 186 .withCuratorId(curator1.value) 187 .withType(CardTypeEnum.URL) 188 .withUrl(url) 189 .buildOrThrow(); 190 191 // Create NOTE card with same URL (shouldn't happen in practice but test edge case) 192 const noteCard = new CardBuilder() 193 .withCuratorId(curator2.value) 194 .withType(CardTypeEnum.NOTE) 195 .withUrl(url) 196 .buildOrThrow(); 197 198 urlCard.addToLibrary(curator1); 199 noteCard.addToLibrary(curator2); 200 201 await cardRepository.save(urlCard); 202 await cardRepository.save(noteCard); 203 204 const result = await queryRepository.getLibrariesForUrl(testUrl, { 205 page: 1, 206 limit: 10, 207 sortBy: CardSortField.UPDATED_AT, 208 sortOrder: SortOrder.DESC, 209 }); 210 211 // Should only return the URL card, not the NOTE card 212 expect(result.items).toHaveLength(1); 213 expect(result.items[0]!.userId).toBe(curator1.value); 214 expect(result.items[0]!.cardId).toBe(urlCard.cardId.getStringValue()); 215 }); 216 217 it('should handle multiple cards from same user with same URL', async () => { 218 const testUrl = 'https://example.com/article'; 219 const url = URL.create(testUrl).unwrap(); 220 221 // Create two URL cards from same user with same URL 222 const card1 = new CardBuilder() 223 .withCuratorId(curator1.value) 224 .withType(CardTypeEnum.URL) 225 .withUrl(url) 226 .buildOrThrow(); 227 228 const card2 = new CardBuilder() 229 .withCuratorId(curator1.value) 230 .withType(CardTypeEnum.URL) 231 .withUrl(url) 232 .buildOrThrow(); 233 234 card1.addToLibrary(curator1); 235 card2.addToLibrary(curator1); 236 237 await cardRepository.save(card1); 238 await cardRepository.save(card2); 239 240 const result = await queryRepository.getLibrariesForUrl(testUrl, { 241 page: 1, 242 limit: 10, 243 sortBy: CardSortField.UPDATED_AT, 244 sortOrder: SortOrder.DESC, 245 }); 246 247 // Should return both cards 248 expect(result.items).toHaveLength(2); 249 expect(result.totalCount).toBe(2); 250 251 // Both should be from the same user 252 expect(result.items[0]!.userId).toBe(curator1.value); 253 expect(result.items[1]!.userId).toBe(curator1.value); 254 255 // But different card IDs 256 const cardIds = result.items.map((lib) => lib.cardId); 257 expect(cardIds).toContain(card1.cardId.getStringValue()); 258 expect(cardIds).toContain(card2.cardId.getStringValue()); 259 }); 260 261 it('should handle cards not in any library', async () => { 262 const testUrl = 'https://example.com/article'; 263 const url = URL.create(testUrl).unwrap(); 264 265 // Create URL card but don't add to library 266 const card = new CardBuilder() 267 .withCuratorId(curator1.value) 268 .withType(CardTypeEnum.URL) 269 .withUrl(url) 270 .buildOrThrow(); 271 272 await cardRepository.save(card); 273 274 const result = await queryRepository.getLibrariesForUrl(testUrl, { 275 page: 1, 276 limit: 10, 277 sortBy: CardSortField.UPDATED_AT, 278 sortOrder: SortOrder.DESC, 279 }); 280 281 // Should return empty since card is not in any library 282 expect(result.items).toHaveLength(0); 283 expect(result.totalCount).toBe(0); 284 }); 285 }); 286 287 describe('pagination', () => { 288 it('should paginate results correctly', async () => { 289 const testUrl = 'https://example.com/popular-article'; 290 const url = URL.create(testUrl).unwrap(); 291 292 // Create 5 cards with the same URL from different users 293 const cards = []; 294 const curators = []; 295 for (let i = 1; i <= 5; i++) { 296 const curator = CuratorId.create(`did:plc:curator${i}`).unwrap(); 297 curators.push(curator); 298 299 const card = new CardBuilder() 300 .withCuratorId(curator.value) 301 .withType(CardTypeEnum.URL) 302 .withUrl(url) 303 .buildOrThrow(); 304 305 card.addToLibrary(curator); 306 cards.push(card); 307 await cardRepository.save(card); 308 } 309 310 // Test first page with limit 2 311 const result1 = await queryRepository.getLibrariesForUrl(testUrl, { 312 page: 1, 313 limit: 2, 314 sortBy: CardSortField.UPDATED_AT, 315 sortOrder: SortOrder.DESC, 316 }); 317 318 expect(result1.items).toHaveLength(2); 319 expect(result1.totalCount).toBe(5); 320 expect(result1.hasMore).toBe(true); 321 322 // Test second page 323 const result2 = await queryRepository.getLibrariesForUrl(testUrl, { 324 page: 2, 325 limit: 2, 326 sortBy: CardSortField.UPDATED_AT, 327 sortOrder: SortOrder.DESC, 328 }); 329 330 expect(result2.items).toHaveLength(2); 331 expect(result2.totalCount).toBe(5); 332 expect(result2.hasMore).toBe(true); 333 334 // Test last page 335 const result3 = await queryRepository.getLibrariesForUrl(testUrl, { 336 page: 3, 337 limit: 2, 338 sortBy: CardSortField.UPDATED_AT, 339 sortOrder: SortOrder.DESC, 340 }); 341 342 expect(result3.items).toHaveLength(1); 343 expect(result3.totalCount).toBe(5); 344 expect(result3.hasMore).toBe(false); 345 346 // Verify no duplicate entries across pages 347 const allUserIds = [ 348 ...result1.items.map((lib) => lib.userId), 349 ...result2.items.map((lib) => lib.userId), 350 ...result3.items.map((lib) => lib.userId), 351 ]; 352 const uniqueUserIds = [...new Set(allUserIds)]; 353 expect(uniqueUserIds).toHaveLength(5); 354 }); 355 356 it('should handle empty pages correctly', async () => { 357 const testUrl = 'https://example.com/empty-test'; 358 359 const result = await queryRepository.getLibrariesForUrl(testUrl, { 360 page: 2, 361 limit: 10, 362 sortBy: CardSortField.UPDATED_AT, 363 sortOrder: SortOrder.DESC, 364 }); 365 366 expect(result.items).toHaveLength(0); 367 expect(result.totalCount).toBe(0); 368 expect(result.hasMore).toBe(false); 369 }); 370 371 it('should handle large page numbers gracefully', async () => { 372 const testUrl = 'https://example.com/single-card'; 373 const url = URL.create(testUrl).unwrap(); 374 375 // Create single card 376 const card = new CardBuilder() 377 .withCuratorId(curator1.value) 378 .withType(CardTypeEnum.URL) 379 .withUrl(url) 380 .buildOrThrow(); 381 382 card.addToLibrary(curator1); 383 await cardRepository.save(card); 384 385 // Request page 10 when there's only 1 item 386 const result = await queryRepository.getLibrariesForUrl(testUrl, { 387 page: 10, 388 limit: 10, 389 sortBy: CardSortField.UPDATED_AT, 390 sortOrder: SortOrder.DESC, 391 }); 392 393 expect(result.items).toHaveLength(0); 394 expect(result.totalCount).toBe(1); 395 expect(result.hasMore).toBe(false); 396 }); 397 }); 398});