A social knowledge tool for researchers built on ATProto
1import { GetNoteCardsForUrlUseCase } from '../../application/useCases/queries/GetNoteCardsForUrlUseCase';
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 { URL } from '../../domain/value-objects/URL';
8import { CardSortField, SortOrder } from '../../domain/ICardQueryRepository';
9import { FakeProfileService } from '../utils/FakeProfileService';
10
11describe('GetNoteCardsForUrlUseCase', () => {
12 let useCase: GetNoteCardsForUrlUseCase;
13 let cardRepository: InMemoryCardRepository;
14 let cardQueryRepository: InMemoryCardQueryRepository;
15 let collectionRepository: InMemoryCollectionRepository;
16 let profileService: FakeProfileService;
17 let curator1: CuratorId;
18 let curator2: CuratorId;
19 let curator3: CuratorId;
20
21 beforeEach(() => {
22 cardRepository = InMemoryCardRepository.getInstance();
23 collectionRepository = InMemoryCollectionRepository.getInstance();
24 cardQueryRepository = new InMemoryCardQueryRepository(
25 cardRepository,
26 collectionRepository,
27 );
28 profileService = new FakeProfileService();
29
30 useCase = new GetNoteCardsForUrlUseCase(
31 cardQueryRepository,
32 profileService,
33 );
34
35 curator1 = CuratorId.create('did:plc:curator1').unwrap();
36 curator2 = CuratorId.create('did:plc:curator2').unwrap();
37 curator3 = CuratorId.create('did:plc:curator3').unwrap();
38
39 // Add profiles for test curators
40 profileService.addProfile({
41 id: curator1.value,
42 name: 'Curator One',
43 handle: 'curator1',
44 avatarUrl: 'https://example.com/avatar1.jpg',
45 });
46
47 profileService.addProfile({
48 id: curator2.value,
49 name: 'Curator Two',
50 handle: 'curator2',
51 avatarUrl: 'https://example.com/avatar2.jpg',
52 });
53
54 profileService.addProfile({
55 id: curator3.value,
56 name: 'Curator Three',
57 handle: 'curator3',
58 avatarUrl: 'https://example.com/avatar3.jpg',
59 });
60 });
61
62 afterEach(() => {
63 cardRepository.clear();
64 collectionRepository.clear();
65 cardQueryRepository.clear();
66 profileService.clear();
67 });
68
69 describe('Multiple users with notes for same URL', () => {
70 it('should return all note cards for the specified URL', async () => {
71 const testUrl = 'https://example.com/shared-article';
72 const url = URL.create(testUrl).unwrap();
73
74 // Create note cards for different users with the same URL
75 const noteCard1 = new CardBuilder()
76 .withCuratorId(curator1.value)
77 .withNoteCard('First user note about the article')
78 .withUrl(url)
79 .buildOrThrow();
80
81 const noteCard2 = new CardBuilder()
82 .withCuratorId(curator2.value)
83 .withNoteCard('Second user note about the article')
84 .withUrl(url)
85 .buildOrThrow();
86
87 const noteCard3 = new CardBuilder()
88 .withCuratorId(curator3.value)
89 .withNoteCard('Third user note about the article')
90 .withUrl(url)
91 .buildOrThrow();
92
93 // Save note cards
94 await cardRepository.save(noteCard1);
95 await cardRepository.save(noteCard2);
96 await cardRepository.save(noteCard3);
97
98 // Execute the use case
99 const query = {
100 url: testUrl,
101 };
102
103 const result = await useCase.execute(query);
104
105 // Verify the result
106 expect(result.isOk()).toBe(true);
107 const response = result.unwrap();
108
109 expect(response.notes).toHaveLength(3);
110 expect(response.pagination.totalCount).toBe(3);
111
112 // Check that all three users' notes are included
113 const authorIds = response.notes.map((note) => note.author.id);
114 expect(authorIds).toContain(curator1.value);
115 expect(authorIds).toContain(curator2.value);
116 expect(authorIds).toContain(curator3.value);
117
118 // Check note content
119 const noteTexts = response.notes.map((note) => note.note);
120 expect(noteTexts).toContain('First user note about the article');
121 expect(noteTexts).toContain('Second user note about the article');
122 expect(noteTexts).toContain('Third user note about the article');
123 });
124
125 it('should return empty result when no notes exist for the URL', async () => {
126 const testUrl = 'https://example.com/nonex istent-article';
127
128 const query = {
129 url: testUrl,
130 };
131
132 const result = await useCase.execute(query);
133
134 expect(result.isOk()).toBe(true);
135 const response = result.unwrap();
136
137 expect(response.notes).toHaveLength(0);
138 expect(response.pagination.totalCount).toBe(0);
139 });
140
141 it('should not return notes for different URLs', async () => {
142 const testUrl1 = 'https://example.com/article1';
143 const testUrl2 = 'https://example.com/article2';
144 const url1 = URL.create(testUrl1).unwrap();
145 const url2 = URL.create(testUrl2).unwrap();
146
147 // Create note cards with different URLs
148 const noteCard1 = new CardBuilder()
149 .withCuratorId(curator1.value)
150 .withNoteCard('Note for article 1')
151 .withUrl(url1)
152 .buildOrThrow();
153
154 const noteCard2 = new CardBuilder()
155 .withCuratorId(curator2.value)
156 .withNoteCard('Note for article 2')
157 .withUrl(url2)
158 .buildOrThrow();
159
160 await cardRepository.save(noteCard1);
161 await cardRepository.save(noteCard2);
162
163 // Query for testUrl1
164 const query = {
165 url: testUrl1,
166 };
167
168 const result = await useCase.execute(query);
169
170 expect(result.isOk()).toBe(true);
171 const response = result.unwrap();
172
173 expect(response.notes).toHaveLength(1);
174 expect(response.notes[0]!.note).toBe('Note for article 1');
175 expect(response.notes[0]!.author.id).toBe(curator1.value);
176 });
177 });
178
179 describe('Pagination', () => {
180 it('should paginate results correctly', async () => {
181 const testUrl = 'https://example.com/popular-article';
182 const url = URL.create(testUrl).unwrap();
183
184 // Create 5 note cards with the same URL from different users
185 for (let i = 1; i <= 5; i++) {
186 const curator = CuratorId.create(`did:plc:curator${i}`).unwrap();
187
188 // Add profile for this curator
189 profileService.addProfile({
190 id: curator.value,
191 name: `Curator ${i}`,
192 handle: `curator${i}`,
193 avatarUrl: `https://example.com/avatar${i}.jpg`,
194 });
195
196 const noteCard = new CardBuilder()
197 .withCuratorId(curator.value)
198 .withNoteCard(`Note ${i} about the article`)
199 .withUrl(url)
200 .buildOrThrow();
201
202 await cardRepository.save(noteCard);
203 }
204
205 // Test first page with limit 2
206 const query1 = {
207 url: testUrl,
208 page: 1,
209 limit: 2,
210 };
211
212 const result1 = await useCase.execute(query1);
213 expect(result1.isOk()).toBe(true);
214 const response1 = result1.unwrap();
215
216 expect(response1.notes).toHaveLength(2);
217 expect(response1.pagination.currentPage).toBe(1);
218 expect(response1.pagination.totalCount).toBe(5);
219 expect(response1.pagination.totalPages).toBe(3);
220 expect(response1.pagination.hasMore).toBe(true);
221
222 // Test second page
223 const query2 = {
224 url: testUrl,
225 page: 2,
226 limit: 2,
227 };
228
229 const result2 = await useCase.execute(query2);
230 expect(result2.isOk()).toBe(true);
231 const response2 = result2.unwrap();
232
233 expect(response2.notes).toHaveLength(2);
234 expect(response2.pagination.currentPage).toBe(2);
235 expect(response2.pagination.hasMore).toBe(true);
236
237 // Test last page
238 const query3 = {
239 url: testUrl,
240 page: 3,
241 limit: 2,
242 };
243
244 const result3 = await useCase.execute(query3);
245 expect(result3.isOk()).toBe(true);
246 const response3 = result3.unwrap();
247
248 expect(response3.notes).toHaveLength(1);
249 expect(response3.pagination.currentPage).toBe(3);
250 expect(response3.pagination.hasMore).toBe(false);
251 });
252
253 it('should respect limit cap of 100', async () => {
254 const query = {
255 url: 'https://example.com/test',
256 limit: 200, // Should be capped at 100
257 };
258
259 const result = await useCase.execute(query);
260 expect(result.isOk()).toBe(true);
261 const response = result.unwrap();
262
263 expect(response.pagination.limit).toBe(100);
264 });
265 });
266
267 describe('Sorting', () => {
268 it('should use default sorting parameters', async () => {
269 const testUrl = 'https://example.com/test-article';
270
271 const query = {
272 url: testUrl,
273 };
274
275 const result = await useCase.execute(query);
276 expect(result.isOk()).toBe(true);
277 const response = result.unwrap();
278
279 expect(response.sorting.sortBy).toBe(CardSortField.UPDATED_AT);
280 expect(response.sorting.sortOrder).toBe(SortOrder.DESC);
281 });
282
283 it('should use provided sorting parameters', async () => {
284 const testUrl = 'https://example.com/test-article';
285
286 const query = {
287 url: testUrl,
288 sortBy: CardSortField.CREATED_AT,
289 sortOrder: SortOrder.ASC,
290 };
291
292 const result = await useCase.execute(query);
293 expect(result.isOk()).toBe(true);
294 const response = result.unwrap();
295
296 expect(response.sorting.sortBy).toBe(CardSortField.CREATED_AT);
297 expect(response.sorting.sortOrder).toBe(SortOrder.ASC);
298 });
299 });
300
301 describe('Validation', () => {
302 it('should fail with invalid URL', async () => {
303 const query = {
304 url: 'not-a-valid-url',
305 };
306
307 const result = await useCase.execute(query);
308
309 expect(result.isErr()).toBe(true);
310 if (result.isErr()) {
311 expect(result.error.message).toContain('Invalid URL');
312 }
313 });
314 });
315});