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});