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 { DrizzleCollectionRepository } from '../../infrastructure/repositories/DrizzleCollectionRepository';
8import { DrizzleCardRepository } from '../../infrastructure/repositories/DrizzleCardRepository';
9import { CollectionId } from '../../domain/value-objects/CollectionId';
10import { CuratorId } from '../../domain/value-objects/CuratorId';
11import { PublishedRecordId } from '../../domain/value-objects/PublishedRecordId';
12import { UniqueEntityID } from '../../../../shared/domain/UniqueEntityID';
13import {
14 collections,
15 collectionCollaborators,
16 collectionCards,
17} from '../../infrastructure/repositories/schema/collection.sql';
18import { cards } from '../../infrastructure/repositories/schema/card.sql';
19import { libraryMemberships } from '../../infrastructure/repositories/schema/libraryMembership.sql';
20import { publishedRecords } from '../../infrastructure/repositories/schema/publishedRecord.sql';
21import { Collection, CollectionAccessType } from '../../domain/Collection';
22import { CardFactory } from '../../domain/CardFactory';
23import { CardTypeEnum } from '../../domain/value-objects/CardType';
24import { createTestSchema } from '../test-utils/createTestSchema';
25
26describe('DrizzleCollectionRepository', () => {
27 let container: StartedPostgreSqlContainer;
28 let db: PostgresJsDatabase;
29 let collectionRepository: DrizzleCollectionRepository;
30 let cardRepository: DrizzleCardRepository;
31
32 // Test data
33 let curatorId: CuratorId;
34 let collaboratorId: CuratorId;
35
36 // Setup before all tests
37 beforeAll(async () => {
38 // Start PostgreSQL container
39 container = await new PostgreSqlContainer('postgres:14').start();
40
41 // Create database connection
42 const connectionString = container.getConnectionUri();
43 process.env.DATABASE_URL = connectionString;
44 const client = postgres(connectionString);
45 db = drizzle(client);
46
47 // Create repositories
48 collectionRepository = new DrizzleCollectionRepository(db);
49 cardRepository = new DrizzleCardRepository(db);
50
51 // Create schema using helper function
52 await createTestSchema(db);
53
54 // Create test data
55 curatorId = CuratorId.create('did:plc:testcurator').unwrap();
56 collaboratorId = CuratorId.create('did:plc:collaborator').unwrap();
57 }, 60000); // Increase timeout for container startup
58
59 // Cleanup after all tests
60 afterAll(async () => {
61 // Stop container
62 await container.stop();
63 });
64
65 // Clear data between tests
66 beforeEach(async () => {
67 await db.delete(collectionCards);
68 await db.delete(collectionCollaborators);
69 await db.delete(collections);
70 await db.delete(libraryMemberships);
71 await db.delete(cards);
72 await db.delete(publishedRecords);
73 });
74
75 it('should save and retrieve a collection', async () => {
76 // Create a collection
77 const collectionId = new UniqueEntityID();
78
79 const collection = Collection.create(
80 {
81 authorId: curatorId,
82 name: 'Test Collection',
83 description: 'A test collection',
84 accessType: CollectionAccessType.OPEN,
85 collaboratorIds: [],
86 createdAt: new Date(),
87 updatedAt: new Date(),
88 },
89 collectionId,
90 ).unwrap();
91
92 // Save the collection
93 const saveResult = await collectionRepository.save(collection);
94 expect(saveResult.isOk()).toBe(true);
95
96 // Retrieve the collection
97 const retrievedResult = await collectionRepository.findById(
98 CollectionId.create(collectionId).unwrap(),
99 );
100 expect(retrievedResult.isOk()).toBe(true);
101
102 const retrievedCollection = retrievedResult.unwrap();
103 expect(retrievedCollection).not.toBeNull();
104 expect(retrievedCollection?.collectionId.getStringValue()).toBe(
105 collectionId.toString(),
106 );
107 expect(retrievedCollection?.authorId.value).toBe(curatorId.value);
108 expect(retrievedCollection?.name.value).toBe('Test Collection');
109 expect(retrievedCollection?.description?.value).toBe('A test collection');
110 expect(retrievedCollection?.accessType).toBe(CollectionAccessType.OPEN);
111 });
112
113 it('should save and retrieve a collection with collaborators', async () => {
114 // Create a collection
115 const collectionId = new UniqueEntityID();
116
117 const collection = Collection.create(
118 {
119 authorId: curatorId,
120 name: 'Collaborative Collection',
121 accessType: CollectionAccessType.CLOSED,
122 collaboratorIds: [],
123 createdAt: new Date(),
124 updatedAt: new Date(),
125 },
126 collectionId,
127 ).unwrap();
128
129 // Add a collaborator
130 const addCollaboratorResult = collection.addCollaborator(
131 collaboratorId,
132 curatorId,
133 );
134 expect(addCollaboratorResult.isOk()).toBe(true);
135
136 // Save the collection
137 const saveResult = await collectionRepository.save(collection);
138 expect(saveResult.isOk()).toBe(true);
139
140 // Retrieve the collection
141 const retrievedResult = await collectionRepository.findById(
142 CollectionId.create(collectionId).unwrap(),
143 );
144 expect(retrievedResult.isOk()).toBe(true);
145
146 const retrievedCollection = retrievedResult.unwrap();
147 expect(retrievedCollection).not.toBeNull();
148 expect(retrievedCollection?.collaboratorIds).toHaveLength(1);
149 expect(retrievedCollection?.collaboratorIds[0]?.value).toBe(
150 collaboratorId.value,
151 );
152 });
153
154 it('should save and retrieve a collection with cards', async () => {
155 // Create a card first
156 const cardResult = CardFactory.create({
157 curatorId: curatorId.value,
158 cardInput: {
159 type: CardTypeEnum.NOTE,
160 text: 'Test card for collection',
161 },
162 });
163
164 const card = cardResult.unwrap();
165 await cardRepository.save(card);
166
167 // Create a collection
168 const collectionId = new UniqueEntityID();
169
170 const collection = Collection.create(
171 {
172 authorId: curatorId,
173 name: 'Collection with Cards',
174 accessType: CollectionAccessType.OPEN,
175 collaboratorIds: [],
176 createdAt: new Date(),
177 updatedAt: new Date(),
178 },
179 collectionId,
180 ).unwrap();
181
182 // Add the card to the collection
183 const addCardResult = collection.addCard(card.cardId, curatorId);
184 expect(addCardResult.isOk()).toBe(true);
185
186 // Save the collection
187 const saveResult = await collectionRepository.save(collection);
188 expect(saveResult.isOk()).toBe(true);
189
190 // Retrieve the collection
191 const retrievedResult = await collectionRepository.findById(
192 CollectionId.create(collectionId).unwrap(),
193 );
194 expect(retrievedResult.isOk()).toBe(true);
195
196 const retrievedCollection = retrievedResult.unwrap();
197 expect(retrievedCollection).not.toBeNull();
198 expect(retrievedCollection?.cardLinks).toHaveLength(1);
199 expect(retrievedCollection?.cardLinks[0]?.cardId.getStringValue()).toBe(
200 card.cardId.getStringValue(),
201 );
202 expect(retrievedCollection?.cardLinks[0]?.addedBy.value).toBe(
203 curatorId.value,
204 );
205 });
206
207 it('should update an existing collection', async () => {
208 // Create a collection
209 const collectionId = new UniqueEntityID();
210
211 const collection = Collection.create(
212 {
213 authorId: curatorId,
214 name: 'Original Name',
215 accessType: CollectionAccessType.CLOSED,
216 collaboratorIds: [],
217 createdAt: new Date(),
218 updatedAt: new Date(),
219 },
220 collectionId,
221 ).unwrap();
222
223 await collectionRepository.save(collection);
224
225 // Update the collection
226 const updatedCollection = Collection.create(
227 {
228 authorId: curatorId,
229 name: 'Updated Name',
230 description: 'Updated description',
231 accessType: CollectionAccessType.CLOSED,
232 collaboratorIds: [],
233 createdAt: new Date(),
234 updatedAt: new Date(),
235 },
236 collectionId,
237 ).unwrap();
238
239 await collectionRepository.save(updatedCollection);
240
241 // Retrieve the updated collection
242 const retrievedResult = await collectionRepository.findById(
243 CollectionId.create(collectionId).unwrap(),
244 );
245 const retrievedCollection = retrievedResult.unwrap();
246
247 expect(retrievedCollection).not.toBeNull();
248 expect(retrievedCollection?.name.value).toBe('Updated Name');
249 expect(retrievedCollection?.description?.value).toBe('Updated description');
250 });
251
252 it('should delete a collection', async () => {
253 // Create a collection
254 const collectionId = new UniqueEntityID();
255
256 const collection = Collection.create(
257 {
258 authorId: curatorId,
259 name: 'Collection to Delete',
260 accessType: CollectionAccessType.OPEN,
261 collaboratorIds: [],
262 createdAt: new Date(),
263 updatedAt: new Date(),
264 },
265 collectionId,
266 ).unwrap();
267
268 await collectionRepository.save(collection);
269
270 // Delete the collection
271 const deleteResult = await collectionRepository.delete(
272 CollectionId.create(collectionId).unwrap(),
273 );
274 expect(deleteResult.isOk()).toBe(true);
275
276 // Try to retrieve the deleted collection
277 const retrievedResult = await collectionRepository.findById(
278 CollectionId.create(collectionId).unwrap(),
279 );
280 expect(retrievedResult.isOk()).toBe(true);
281 expect(retrievedResult.unwrap()).toBeNull();
282 });
283
284 it('should find collections by curator ID', async () => {
285 // Create multiple collections for the same curator
286 const collection1Id = new UniqueEntityID();
287 const collection1 = Collection.create(
288 {
289 authorId: curatorId,
290 name: 'First Collection',
291 accessType: CollectionAccessType.OPEN,
292 collaboratorIds: [],
293 createdAt: new Date(),
294 updatedAt: new Date(),
295 },
296 collection1Id,
297 ).unwrap();
298
299 const collection2Id = new UniqueEntityID();
300 const collection2 = Collection.create(
301 {
302 authorId: curatorId,
303 name: 'Second Collection',
304 accessType: CollectionAccessType.CLOSED,
305 collaboratorIds: [],
306 createdAt: new Date(),
307 updatedAt: new Date(),
308 },
309 collection2Id,
310 ).unwrap();
311
312 await collectionRepository.save(collection1);
313 await collectionRepository.save(collection2);
314
315 // Find collections by curator ID
316 const foundCollectionsResult =
317 await collectionRepository.findByCuratorId(curatorId);
318 expect(foundCollectionsResult.isOk()).toBe(true);
319
320 const foundCollections = foundCollectionsResult.unwrap();
321 expect(foundCollections).toHaveLength(2);
322
323 const names = foundCollections.map((c) => c.name.value);
324 expect(names).toContain('First Collection');
325 expect(names).toContain('Second Collection');
326 });
327
328 it('should find collections by card ID', async () => {
329 // Create a card
330 const cardResult = CardFactory.create({
331 curatorId: curatorId.value,
332 cardInput: {
333 type: CardTypeEnum.NOTE,
334 text: 'Shared card',
335 },
336 });
337
338 const card = cardResult.unwrap();
339 await cardRepository.save(card);
340
341 // Create multiple collections and add the card to them
342 const collection1Id = new UniqueEntityID();
343 const collection1 = Collection.create(
344 {
345 authorId: curatorId,
346 name: 'Collection One',
347 accessType: CollectionAccessType.OPEN,
348 collaboratorIds: [],
349 createdAt: new Date(),
350 updatedAt: new Date(),
351 },
352 collection1Id,
353 ).unwrap();
354
355 const collection2Id = new UniqueEntityID();
356 const collection2 = Collection.create(
357 {
358 authorId: curatorId,
359 name: 'Collection Two',
360 accessType: CollectionAccessType.CLOSED,
361 collaboratorIds: [],
362 createdAt: new Date(),
363 updatedAt: new Date(),
364 },
365 collection2Id,
366 ).unwrap();
367
368 // Add card to both collections
369 collection1.addCard(card.cardId, curatorId);
370 collection2.addCard(card.cardId, curatorId);
371
372 await collectionRepository.save(collection1);
373 await collectionRepository.save(collection2);
374
375 // Find collections by card ID
376 const foundCollectionsResult = await collectionRepository.findByCardId(
377 card.cardId,
378 );
379 expect(foundCollectionsResult.isOk()).toBe(true);
380
381 const foundCollections = foundCollectionsResult.unwrap();
382 expect(foundCollections).toHaveLength(2);
383
384 const names = foundCollections.map((c) => c.name.value);
385 expect(names).toContain('Collection One');
386 expect(names).toContain('Collection Two');
387 });
388
389 it('should save and retrieve a collection with published record', async () => {
390 // Create a collection
391 const collectionId = new UniqueEntityID();
392
393 const collection = Collection.create(
394 {
395 authorId: curatorId,
396 name: 'Published Collection',
397 accessType: CollectionAccessType.OPEN,
398 collaboratorIds: [],
399 createdAt: new Date(),
400 updatedAt: new Date(),
401 },
402 collectionId,
403 ).unwrap();
404
405 // Mark as published
406 const publishedRecordId = PublishedRecordId.create({
407 uri: 'at://did:plc:testcurator/network.cosmik.collection/1234',
408 cid: 'bafyreihgmyh2srmmyj7g7vmah3ietpwdwcgda2jof7hkfxmcbbjwejnqwu',
409 });
410
411 collection.markAsPublished(publishedRecordId);
412
413 // Save the collection
414 const saveResult = await collectionRepository.save(collection);
415 expect(saveResult.isOk()).toBe(true);
416
417 // Retrieve the collection
418 const retrievedResult = await collectionRepository.findById(
419 CollectionId.create(collectionId).unwrap(),
420 );
421 expect(retrievedResult.isOk()).toBe(true);
422
423 const retrievedCollection = retrievedResult.unwrap();
424 expect(retrievedCollection).not.toBeNull();
425 expect(retrievedCollection?.publishedRecordId?.uri).toBe(
426 'at://did:plc:testcurator/network.cosmik.collection/1234',
427 );
428 expect(retrievedCollection?.publishedRecordId?.cid).toBe(
429 'bafyreihgmyh2srmmyj7g7vmah3ietpwdwcgda2jof7hkfxmcbbjwejnqwu',
430 );
431 });
432
433 it('should handle card links with published records', async () => {
434 // Create a card
435 const cardResult = CardFactory.create({
436 curatorId: curatorId.value,
437 cardInput: {
438 type: CardTypeEnum.NOTE,
439 text: 'Card with published link',
440 },
441 });
442
443 const card = cardResult.unwrap();
444 await cardRepository.save(card);
445
446 // Create a collection
447 const collectionId = new UniqueEntityID();
448 const collection = Collection.create(
449 {
450 authorId: curatorId,
451 name: 'Collection with Published Links',
452 accessType: CollectionAccessType.OPEN,
453 collaboratorIds: [],
454 createdAt: new Date(),
455 updatedAt: new Date(),
456 },
457 collectionId,
458 ).unwrap();
459
460 // Add card to collection
461 collection.addCard(card.cardId, curatorId);
462
463 // Mark the card link as published
464 const linkPublishedRecord = PublishedRecordId.create({
465 uri: 'at://did:plc:testcurator/network.cosmik.collectionLink/5678',
466 cid: 'bafyreihgmyh2srmmyj7g7vmah3ietpwdwcgda2jof7hkfxmcbbjwejnqwu',
467 });
468
469 collection.markCardLinkAsPublished(card.cardId, linkPublishedRecord);
470
471 // Save the collection
472 const saveResult = await collectionRepository.save(collection);
473 expect(saveResult.isOk()).toBe(true);
474
475 // Retrieve the collection
476 const retrievedResult = await collectionRepository.findById(
477 CollectionId.create(collectionId).unwrap(),
478 );
479 expect(retrievedResult.isOk()).toBe(true);
480
481 const retrievedCollection = retrievedResult.unwrap();
482 expect(retrievedCollection).not.toBeNull();
483 expect(retrievedCollection?.cardLinks).toHaveLength(1);
484 expect(retrievedCollection?.cardLinks[0]?.publishedRecordId?.uri).toBe(
485 'at://did:plc:testcurator/network.cosmik.collectionLink/5678',
486 );
487 });
488
489 it('should find collections by author ID containing a specific card', async () => {
490 // Create a card
491 const cardResult = CardFactory.create({
492 curatorId: curatorId.value,
493 cardInput: {
494 type: CardTypeEnum.NOTE,
495 text: 'Shared card for author test',
496 },
497 });
498
499 const card = cardResult.unwrap();
500 await cardRepository.save(card);
501
502 // Create collections by the same author
503 const collection1Id = new UniqueEntityID();
504 const collection1 = Collection.create(
505 {
506 authorId: curatorId,
507 name: 'Author Collection One',
508 accessType: CollectionAccessType.OPEN,
509 collaboratorIds: [],
510 createdAt: new Date(),
511 updatedAt: new Date(),
512 },
513 collection1Id,
514 ).unwrap();
515
516 const collection2Id = new UniqueEntityID();
517 const collection2 = Collection.create(
518 {
519 authorId: curatorId,
520 name: 'Author Collection Two',
521 accessType: CollectionAccessType.CLOSED,
522 collaboratorIds: [],
523 createdAt: new Date(),
524 updatedAt: new Date(),
525 },
526 collection2Id,
527 ).unwrap();
528
529 // Create a collection by a different author
530 const collection3Id = new UniqueEntityID();
531 const collection3 = Collection.create(
532 {
533 authorId: collaboratorId,
534 name: 'Different Author Collection',
535 accessType: CollectionAccessType.OPEN,
536 collaboratorIds: [],
537 createdAt: new Date(),
538 updatedAt: new Date(),
539 },
540 collection3Id,
541 ).unwrap();
542
543 // Add card to all collections
544 collection1.addCard(card.cardId, curatorId);
545 collection2.addCard(card.cardId, curatorId);
546 collection3.addCard(card.cardId, collaboratorId);
547
548 await collectionRepository.save(collection1);
549 await collectionRepository.save(collection2);
550 await collectionRepository.save(collection3);
551
552 // Find collections by the original curator containing this card
553 const foundCollectionsResult =
554 await collectionRepository.findByCuratorIdContainingCard(
555 curatorId,
556 card.cardId,
557 );
558 expect(foundCollectionsResult.isOk()).toBe(true);
559
560 const foundCollections = foundCollectionsResult.unwrap();
561 expect(foundCollections).toHaveLength(2);
562
563 const names = foundCollections.map((c) => c.name.value);
564 expect(names).toContain('Author Collection One');
565 expect(names).toContain('Author Collection Two');
566 expect(names).not.toContain('Different Author Collection');
567
568 // Verify all returned collections are authored by the correct curator
569 foundCollections.forEach((collection) => {
570 expect(collection.authorId.value).toBe(curatorId.value);
571 });
572 });
573});