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