A social knowledge tool for researchers built on ATProto
at main 391 lines 12 kB view raw
1import { GetLibrariesForUrlUseCase } from '../../application/useCases/queries/GetLibrariesForUrlUseCase'; 2import { InMemoryCardRepository } from '../utils/InMemoryCardRepository'; 3import { InMemoryCardQueryRepository } from '../utils/InMemoryCardQueryRepository'; 4import { InMemoryCollectionRepository } from '../utils/InMemoryCollectionRepository'; 5import { FakeProfileService } from '../utils/FakeProfileService'; 6import { CuratorId } from '../../domain/value-objects/CuratorId'; 7import { CardBuilder } from '../utils/builders/CardBuilder'; 8import { CardTypeEnum } from '../../domain/value-objects/CardType'; 9import { URL } from '../../domain/value-objects/URL'; 10import { CardSortField, SortOrder } from '../../domain/ICardQueryRepository'; 11 12describe('GetLibrariesForUrlUseCase', () => { 13 let useCase: GetLibrariesForUrlUseCase; 14 let cardRepository: InMemoryCardRepository; 15 let cardQueryRepository: InMemoryCardQueryRepository; 16 let collectionRepository: InMemoryCollectionRepository; 17 let profileService: FakeProfileService; 18 let curator1: CuratorId; 19 let curator2: CuratorId; 20 let curator3: CuratorId; 21 22 beforeEach(() => { 23 cardRepository = InMemoryCardRepository.getInstance(); 24 collectionRepository = InMemoryCollectionRepository.getInstance(); 25 cardQueryRepository = new InMemoryCardQueryRepository( 26 cardRepository, 27 collectionRepository, 28 ); 29 profileService = new FakeProfileService(); 30 31 useCase = new GetLibrariesForUrlUseCase( 32 cardQueryRepository, 33 profileService, 34 ); 35 36 curator1 = CuratorId.create('did:plc:curator1').unwrap(); 37 curator2 = CuratorId.create('did:plc:curator2').unwrap(); 38 curator3 = CuratorId.create('did:plc:curator3').unwrap(); 39 40 // Set up profiles 41 profileService.addProfile({ 42 id: curator1.value, 43 name: 'Curator One', 44 handle: 'curator1', 45 avatarUrl: 'https://example.com/avatar1.jpg', 46 bio: 'Curator 1 bio', 47 }); 48 49 profileService.addProfile({ 50 id: curator2.value, 51 name: 'Curator Two', 52 handle: 'curator2', 53 avatarUrl: 'https://example.com/avatar2.jpg', 54 bio: 'Curator 2 bio', 55 }); 56 57 profileService.addProfile({ 58 id: curator3.value, 59 name: 'Curator Three', 60 handle: 'curator3', 61 avatarUrl: 'https://example.com/avatar3.jpg', 62 bio: 'Curator 3 bio', 63 }); 64 }); 65 66 afterEach(() => { 67 cardRepository.clear(); 68 collectionRepository.clear(); 69 cardQueryRepository.clear(); 70 profileService.clear(); 71 }); 72 73 describe('Multiple users with same URL', () => { 74 it('should return all users who have cards with the specified URL', async () => { 75 const testUrl = 'https://example.com/shared-article'; 76 const url = URL.create(testUrl).unwrap(); 77 78 // Create URL cards for different users with the same URL 79 const card1 = new CardBuilder() 80 .withCuratorId(curator1.value) 81 .withType(CardTypeEnum.URL) 82 .withUrl(url) 83 .build(); 84 85 const card2 = new CardBuilder() 86 .withCuratorId(curator2.value) 87 .withType(CardTypeEnum.URL) 88 .withUrl(url) 89 .build(); 90 91 const card3 = new CardBuilder() 92 .withCuratorId(curator3.value) 93 .withType(CardTypeEnum.URL) 94 .withUrl(url) 95 .build(); 96 97 if ( 98 card1 instanceof Error || 99 card2 instanceof Error || 100 card3 instanceof Error 101 ) { 102 throw new Error('Failed to create cards'); 103 } 104 105 // Add cards to their respective libraries 106 card1.addToLibrary(curator1); 107 card2.addToLibrary(curator2); 108 card3.addToLibrary(curator3); 109 110 // Save cards 111 await cardRepository.save(card1); 112 await cardRepository.save(card2); 113 await cardRepository.save(card3); 114 115 // Execute the use case 116 const query = { 117 url: testUrl, 118 }; 119 120 const result = await useCase.execute(query); 121 122 // Verify the result 123 expect(result.isOk()).toBe(true); 124 const response = result.unwrap(); 125 126 expect(response.libraries).toHaveLength(3); 127 expect(response.pagination.totalCount).toBe(3); 128 129 // Check that all three users are included 130 const userIds = response.libraries.map((lib) => lib.user.id); 131 expect(userIds).toContain(curator1.value); 132 expect(userIds).toContain(curator2.value); 133 expect(userIds).toContain(curator3.value); 134 135 // Check that card IDs are correct 136 const cardIds = response.libraries.map((lib) => lib.card.id); 137 expect(cardIds).toContain(card1.cardId.getStringValue()); 138 expect(cardIds).toContain(card2.cardId.getStringValue()); 139 expect(cardIds).toContain(card3.cardId.getStringValue()); 140 }); 141 142 it('should return empty result when no users have cards with the specified URL', async () => { 143 const testUrl = 'https://example.com/nonexistent-article'; 144 145 const query = { 146 url: testUrl, 147 }; 148 149 const result = await useCase.execute(query); 150 151 expect(result.isOk()).toBe(true); 152 const response = result.unwrap(); 153 154 expect(response.libraries).toHaveLength(0); 155 expect(response.pagination.totalCount).toBe(0); 156 }); 157 158 it('should not return users who have different URLs', async () => { 159 const testUrl1 = 'https://example.com/article1'; 160 const testUrl2 = 'https://example.com/article2'; 161 const url1 = URL.create(testUrl1).unwrap(); 162 const url2 = URL.create(testUrl2).unwrap(); 163 164 // Create cards with different URLs 165 const card1 = new CardBuilder() 166 .withCuratorId(curator1.value) 167 .withType(CardTypeEnum.URL) 168 .withUrl(url1) 169 .build(); 170 171 const card2 = new CardBuilder() 172 .withCuratorId(curator2.value) 173 .withType(CardTypeEnum.URL) 174 .withUrl(url2) 175 .build(); 176 177 if (card1 instanceof Error || card2 instanceof Error) { 178 throw new Error('Failed to create cards'); 179 } 180 181 card1.addToLibrary(curator1); 182 card2.addToLibrary(curator2); 183 184 await cardRepository.save(card1); 185 await cardRepository.save(card2); 186 187 // Query for testUrl1 188 const query = { 189 url: testUrl1, 190 }; 191 192 const result = await useCase.execute(query); 193 194 expect(result.isOk()).toBe(true); 195 const response = result.unwrap(); 196 197 expect(response.libraries).toHaveLength(1); 198 expect(response.libraries[0]!.user.id).toBe(curator1.value); 199 expect(response.libraries[0]!.card.id).toBe( 200 card1.cardId.getStringValue(), 201 ); 202 }); 203 }); 204 205 describe('Pagination', () => { 206 it('should paginate results correctly', async () => { 207 const testUrl = 'https://example.com/popular-article'; 208 const url = URL.create(testUrl).unwrap(); 209 210 // Create 5 cards with the same URL from different users 211 const cards = []; 212 const curators = []; 213 for (let i = 1; i <= 5; i++) { 214 const curator = CuratorId.create(`did:plc:curator${i}`).unwrap(); 215 curators.push(curator); 216 217 // Add profiles for curator4 and curator5 (curator1-3 already set up in beforeEach) 218 if (i > 3) { 219 profileService.addProfile({ 220 id: curator.value, 221 name: `Curator ${i}`, 222 handle: `curator${i}`, 223 avatarUrl: `https://example.com/avatar${i}.jpg`, 224 bio: `Curator ${i} bio`, 225 }); 226 } 227 228 const card = new CardBuilder() 229 .withCuratorId(curator.value) 230 .withType(CardTypeEnum.URL) 231 .withUrl(url) 232 .build(); 233 234 if (card instanceof Error) { 235 throw new Error(`Failed to create card ${i}`); 236 } 237 238 card.addToLibrary(curator); 239 cards.push(card); 240 await cardRepository.save(card); 241 } 242 243 // Test first page with limit 2 244 const query1 = { 245 url: testUrl, 246 page: 1, 247 limit: 2, 248 }; 249 250 const result1 = await useCase.execute(query1); 251 if (result1.isErr()) { 252 throw new Error( 253 `Use case failed: ${result1.error.message || result1.error}`, 254 ); 255 } 256 expect(result1.isOk()).toBe(true); 257 const response1 = result1.unwrap(); 258 259 expect(response1.libraries).toHaveLength(2); 260 expect(response1.pagination.currentPage).toBe(1); 261 expect(response1.pagination.totalCount).toBe(5); 262 expect(response1.pagination.totalPages).toBe(3); 263 expect(response1.pagination.hasMore).toBe(true); 264 265 // Test second page 266 const query2 = { 267 url: testUrl, 268 page: 2, 269 limit: 2, 270 }; 271 272 const result2 = await useCase.execute(query2); 273 expect(result2.isOk()).toBe(true); 274 const response2 = result2.unwrap(); 275 276 expect(response2.libraries).toHaveLength(2); 277 expect(response2.pagination.currentPage).toBe(2); 278 expect(response2.pagination.hasMore).toBe(true); 279 280 // Test last page 281 const query3 = { 282 url: testUrl, 283 page: 3, 284 limit: 2, 285 }; 286 287 const result3 = await useCase.execute(query3); 288 expect(result3.isOk()).toBe(true); 289 const response3 = result3.unwrap(); 290 291 expect(response3.libraries).toHaveLength(1); 292 expect(response3.pagination.currentPage).toBe(3); 293 expect(response3.pagination.hasMore).toBe(false); 294 }); 295 296 it('should respect limit cap of 100', async () => { 297 const query = { 298 url: 'https://example.com/test', 299 limit: 200, // Should be capped at 100 300 }; 301 302 const result = await useCase.execute(query); 303 expect(result.isOk()).toBe(true); 304 const response = result.unwrap(); 305 306 expect(response.pagination.limit).toBe(100); 307 }); 308 }); 309 310 describe('Sorting', () => { 311 it('should use default sorting parameters', async () => { 312 const testUrl = 'https://example.com/test-article'; 313 314 const query = { 315 url: testUrl, 316 }; 317 318 const result = await useCase.execute(query); 319 expect(result.isOk()).toBe(true); 320 const response = result.unwrap(); 321 322 expect(response.sorting.sortBy).toBe(CardSortField.CREATED_AT); 323 expect(response.sorting.sortOrder).toBe(SortOrder.DESC); 324 }); 325 326 it('should use provided sorting parameters', async () => { 327 const testUrl = 'https://example.com/test-article'; 328 329 const query = { 330 url: testUrl, 331 sortBy: CardSortField.CREATED_AT, 332 sortOrder: SortOrder.ASC, 333 }; 334 335 const result = await useCase.execute(query); 336 expect(result.isOk()).toBe(true); 337 const response = result.unwrap(); 338 339 expect(response.sorting.sortBy).toBe(CardSortField.CREATED_AT); 340 expect(response.sorting.sortOrder).toBe(SortOrder.ASC); 341 }); 342 }); 343 344 describe('Validation', () => { 345 it('should fail with invalid URL', async () => { 346 const query = { 347 url: 'not-a-valid-url', 348 }; 349 350 const result = await useCase.execute(query); 351 352 expect(result.isErr()).toBe(true); 353 if (result.isErr()) { 354 expect(result.error.message).toContain('Invalid URL'); 355 } 356 }); 357 }); 358 359 describe('Error handling', () => { 360 it('should handle repository errors gracefully', async () => { 361 // Create a mock repository that throws an error 362 const errorCardQueryRepository = { 363 getUrlCardsOfUser: jest.fn(), 364 getCardsInCollection: jest.fn(), 365 getUrlCardView: jest.fn(), 366 getUrlCardBasic: jest.fn(), 367 getLibrariesForCard: jest.fn(), 368 getLibrariesForUrl: jest 369 .fn() 370 .mockRejectedValue(new Error('Database error')), 371 getNoteCardsForUrl: jest.fn(), 372 }; 373 374 const errorUseCase = new GetLibrariesForUrlUseCase( 375 errorCardQueryRepository, 376 profileService, 377 ); 378 379 const query = { 380 url: 'https://example.com/test-url', 381 }; 382 383 const result = await errorUseCase.execute(query); 384 385 expect(result.isErr()).toBe(true); 386 if (result.isErr()) { 387 expect(result.error.message).toContain('Database error'); 388 } 389 }); 390 }); 391});