A social knowledge tool for researchers built on ATProto
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});