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