This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

semble / src / modules / notifications / tests / infrastructure / DrizzleNotificationRepository.findByRecipientEnriched.integration.test.ts
24 kB 665 lines
1import { 2 PostgreSqlContainer, 3 StartedPostgreSqlContainer, 4} from '@testcontainers/postgresql'; 5import postgres from 'postgres'; 6import { drizzle, PostgresJsDatabase } from 'drizzle-orm/postgres-js'; 7import { DrizzleNotificationRepository } from '../../infrastructure/repositories/DrizzleNotificationRepository'; 8import { DrizzleCardRepository } from '../../../cards/infrastructure/repositories/DrizzleCardRepository'; 9import { DrizzleCollectionRepository } from '../../../cards/infrastructure/repositories/DrizzleCollectionRepository'; 10import { CuratorId } from '../../../cards/domain/value-objects/CuratorId'; 11import { UniqueEntityID } from '../../../../shared/domain/UniqueEntityID'; 12import { cards } from '../../../cards/infrastructure/repositories/schema/card.sql'; 13import { 14 collections, 15 collectionCards, 16} from '../../../cards/infrastructure/repositories/schema/collection.sql'; 17import { libraryMemberships } from '../../../cards/infrastructure/repositories/schema/libraryMembership.sql'; 18import { publishedRecords } from '../../../cards/infrastructure/repositories/schema/publishedRecord.sql'; 19import { notifications } from '../../infrastructure/repositories/schema/notification.sql'; 20import { 21 Collection, 22 CollectionAccessType, 23} from '../../../cards/domain/Collection'; 24import { CardBuilder } from '../../../cards/tests/utils/builders/CardBuilder'; 25import { URL } from '../../../cards/domain/value-objects/URL'; 26import { UrlMetadata } from '../../../cards/domain/value-objects/UrlMetadata'; 27import { createTestSchema } from '../../../cards/tests/test-utils/createTestSchema'; 28import { Notification } from '../../domain/Notification'; 29import { NotificationType } from '../../domain/value-objects/NotificationType'; 30import { NotificationType as NotificationTypeEnum } from '@semble/types'; 31import { UrlType } from '../../../cards/domain/value-objects/UrlType'; 32 33describe('DrizzleNotificationRepository - findByRecipientEnriched', () => { 34 let container: StartedPostgreSqlContainer; 35 let db: PostgresJsDatabase; 36 let notificationRepository: DrizzleNotificationRepository; 37 let cardRepository: DrizzleCardRepository; 38 let collectionRepository: DrizzleCollectionRepository; 39 40 // Test data 41 let recipientId: CuratorId; 42 let actorId: CuratorId; 43 let cardAuthorId: CuratorId; 44 let collectionAuthorId: CuratorId; 45 46 // Setup before all tests 47 beforeAll(async () => { 48 // Start PostgreSQL container 49 container = await new PostgreSqlContainer('postgres:14').start(); 50 51 // Create database connection 52 const connectionString = container.getConnectionUri(); 53 process.env.DATABASE_URL = connectionString; 54 const client = postgres(connectionString); 55 db = drizzle(client); 56 57 // Create repositories 58 notificationRepository = new DrizzleNotificationRepository(db); 59 cardRepository = new DrizzleCardRepository(db); 60 collectionRepository = new DrizzleCollectionRepository(db); 61 62 // Create schema using helper function 63 await createTestSchema(db); 64 65 // Create test data 66 recipientId = CuratorId.create('did:plc:recipient').unwrap(); 67 actorId = CuratorId.create('did:plc:actor').unwrap(); 68 cardAuthorId = CuratorId.create('did:plc:cardauthor').unwrap(); 69 collectionAuthorId = CuratorId.create('did:plc:collectionauthor').unwrap(); 70 }, 60000); // Increase timeout for container startup 71 72 // Cleanup after all tests 73 afterAll(async () => { 74 // Stop container 75 await container.stop(); 76 }); 77 78 // Clear data between tests 79 beforeEach(async () => { 80 await db.delete(notifications); 81 await db.delete(collectionCards); 82 await db.delete(collections); 83 await db.delete(libraryMemberships); 84 await db.delete(cards); 85 await db.delete(publishedRecords); 86 }); 87 88 describe('findByRecipientEnriched', () => { 89 it('should return empty result when no notifications exist', async () => { 90 const result = await notificationRepository.findByRecipientEnriched( 91 recipientId, 92 { page: 1, limit: 10 }, 93 ); 94 95 expect(result.isOk()).toBe(true); 96 const data = result.unwrap(); 97 expect(data.notifications).toEqual([]); 98 expect(data.totalCount).toBe(0); 99 expect(data.hasMore).toBe(false); 100 expect(data.unreadCount).toBe(0); 101 }); 102 103 it('should return enriched notification with card data', async () => { 104 // Create URL card with metadata 105 const url = URL.create('https://example.com/test-article').unwrap(); 106 const urlMetadata = UrlMetadata.create({ 107 url: url.value, 108 title: 'Test Article', 109 description: 'A test article description', 110 author: 'John Doe', 111 publishedDate: new Date('2024-01-15'), 112 siteName: 'Example Site', 113 imageUrl: 'https://example.com/image.jpg', 114 type: UrlType.ARTICLE, 115 doi: '10.1000/test', 116 isbn: '978-0123456789', 117 }).unwrap(); 118 119 const urlCard = new CardBuilder() 120 .withCuratorId(cardAuthorId.value) 121 .withUrlCard(url, urlMetadata) 122 .buildOrThrow(); 123 124 await cardRepository.save(urlCard); 125 126 // Add card to library to get library count 127 urlCard.addToLibrary(cardAuthorId); 128 await cardRepository.save(urlCard); 129 130 // Create notification 131 const notification = Notification.create({ 132 recipientUserId: recipientId, 133 actorUserId: actorId, 134 type: NotificationType.create( 135 NotificationTypeEnum.USER_ADDED_YOUR_CARD, 136 ).unwrap(), 137 metadata: { 138 cardId: urlCard.cardId.getStringValue(), 139 }, 140 read: false, 141 createdAt: new Date('2024-01-20'), 142 updatedAt: new Date('2024-01-20'), 143 }).unwrap(); 144 145 await notificationRepository.save(notification); 146 147 const result = await notificationRepository.findByRecipientEnriched( 148 recipientId, 149 { page: 1, limit: 10 }, 150 ); 151 152 expect(result.isOk()).toBe(true); 153 const data = result.unwrap(); 154 expect(data.notifications).toHaveLength(1); 155 expect(data.totalCount).toBe(1); 156 expect(data.hasMore).toBe(false); 157 expect(data.unreadCount).toBe(1); 158 159 const enrichedNotification = data.notifications[0]!; 160 161 // Check notification data 162 expect(enrichedNotification.id).toBe( 163 notification.notificationId.getStringValue(), 164 ); 165 expect(enrichedNotification.type).toBe( 166 NotificationTypeEnum.USER_ADDED_YOUR_CARD, 167 ); 168 expect(enrichedNotification.read).toBe(false); 169 expect(enrichedNotification.actorUserId).toBe(actorId.value); 170 expect(enrichedNotification.cardAuthorId).toBe(cardAuthorId.value); 171 172 // Check card data 173 expect(enrichedNotification.cardId).toBe(urlCard.cardId.getStringValue()); 174 expect(enrichedNotification.cardUrl).toBe(url.value); 175 expect(enrichedNotification.cardTitle).toBe('Test Article'); 176 expect(enrichedNotification.cardDescription).toBe( 177 'A test article description', 178 ); 179 expect(enrichedNotification.cardAuthor).toBe('John Doe'); 180 expect(enrichedNotification.cardSiteName).toBe('Example Site'); 181 expect(enrichedNotification.cardImageUrl).toBe( 182 'https://example.com/image.jpg', 183 ); 184 expect(enrichedNotification.cardType).toBe('article'); 185 expect(enrichedNotification.cardDoi).toBe('10.1000/test'); 186 expect(enrichedNotification.cardIsbn).toBe('978-0123456789'); 187 expect(enrichedNotification.cardLibraryCount).toBe(1); 188 expect(enrichedNotification.cardUrlLibraryCount).toBe(1); 189 expect(enrichedNotification.collections).toEqual([]); 190 }); 191 192 it('should include note card data when present', async () => { 193 // Create URL card 194 const url = URL.create('https://example.com/article-with-note').unwrap(); 195 const urlCard = new CardBuilder() 196 .withCuratorId(cardAuthorId.value) 197 .withUrlCard(url) 198 .buildOrThrow(); 199 200 await cardRepository.save(urlCard); 201 202 // Create note card connected to URL card 203 const noteCard = new CardBuilder() 204 .withCuratorId(cardAuthorId.value) 205 .withNoteCard('This is my detailed analysis of the article.') 206 .withParentCard(urlCard.cardId) 207 .buildOrThrow(); 208 209 await cardRepository.save(noteCard); 210 211 // Create notification 212 const notification = Notification.create({ 213 recipientUserId: recipientId, 214 actorUserId: actorId, 215 type: NotificationType.create( 216 NotificationTypeEnum.USER_ADDED_YOUR_CARD, 217 ).unwrap(), 218 metadata: { 219 cardId: urlCard.cardId.getStringValue(), 220 }, 221 read: false, 222 createdAt: new Date(), 223 updatedAt: new Date(), 224 }).unwrap(); 225 226 await notificationRepository.save(notification); 227 228 const result = await notificationRepository.findByRecipientEnriched( 229 recipientId, 230 { page: 1, limit: 10 }, 231 ); 232 233 expect(result.isOk()).toBe(true); 234 const data = result.unwrap(); 235 expect(data.notifications).toHaveLength(1); 236 237 const enrichedNotification = data.notifications[0]!; 238 expect(enrichedNotification.cardNote).toBeDefined(); 239 expect(enrichedNotification.cardNote?.id).toBe( 240 noteCard.cardId.getStringValue(), 241 ); 242 expect(enrichedNotification.cardNote?.text).toBe( 243 'This is my detailed analysis of the article.', 244 ); 245 }); 246 247 it('should include collection data when card is in collections', async () => { 248 // Create URL card 249 const url = URL.create( 250 'https://example.com/article-in-collections', 251 ).unwrap(); 252 const urlCard = new CardBuilder() 253 .withCuratorId(cardAuthorId.value) 254 .withUrlCard(url) 255 .buildOrThrow(); 256 257 await cardRepository.save(urlCard); 258 259 // Create collections 260 const collection1 = Collection.create( 261 { 262 authorId: collectionAuthorId, 263 name: 'Reading List', 264 description: 'Articles to read', 265 accessType: CollectionAccessType.OPEN, 266 collaboratorIds: [], 267 createdAt: new Date('2024-01-10'), 268 updatedAt: new Date('2024-01-15'), 269 }, 270 new UniqueEntityID(), 271 ).unwrap(); 272 273 const collection2 = Collection.create( 274 { 275 authorId: cardAuthorId, 276 name: 'My Favorites', 277 accessType: CollectionAccessType.CLOSED, 278 collaboratorIds: [], 279 createdAt: new Date('2024-01-12'), 280 updatedAt: new Date('2024-01-18'), 281 }, 282 new UniqueEntityID(), 283 ).unwrap(); 284 285 // Add card to collections 286 collection1.addCard(urlCard.cardId, collectionAuthorId); 287 collection2.addCard(urlCard.cardId, cardAuthorId); 288 289 await collectionRepository.save(collection1); 290 await collectionRepository.save(collection2); 291 292 // Create notification 293 const notification = Notification.create({ 294 recipientUserId: recipientId, 295 actorUserId: actorId, 296 type: NotificationType.create( 297 NotificationTypeEnum.USER_ADDED_YOUR_CARD, 298 ).unwrap(), 299 metadata: { 300 cardId: urlCard.cardId.getStringValue(), 301 collectionIds: [ 302 collection1.collectionId.getStringValue(), 303 collection2.collectionId.getStringValue(), 304 ], 305 }, 306 read: false, 307 createdAt: new Date(), 308 updatedAt: new Date(), 309 }).unwrap(); 310 311 await notificationRepository.save(notification); 312 313 const result = await notificationRepository.findByRecipientEnriched( 314 recipientId, 315 { page: 1, limit: 10 }, 316 ); 317 318 expect(result.isOk()).toBe(true); 319 const data = result.unwrap(); 320 expect(data.notifications).toHaveLength(1); 321 322 const enrichedNotification = data.notifications[0]!; 323 expect(enrichedNotification.collections).toHaveLength(2); 324 325 // Check collection data 326 const collectionNames = enrichedNotification.collections 327 .map((c) => c.name) 328 .sort(); 329 expect(collectionNames).toEqual(['My Favorites', 'Reading List']); 330 331 const readingList = enrichedNotification.collections.find( 332 (c) => c.name === 'Reading List', 333 ); 334 expect(readingList?.id).toBe(collection1.collectionId.getStringValue()); 335 expect(readingList?.description).toBe('Articles to read'); 336 expect(readingList?.authorId).toBe(collectionAuthorId.value); 337 expect(readingList?.cardCount).toBe(1); 338 expect(readingList?.createdAt).toEqual(new Date('2024-01-10')); 339 340 const myFavorites = enrichedNotification.collections.find( 341 (c) => c.name === 'My Favorites', 342 ); 343 expect(myFavorites?.id).toBe(collection2.collectionId.getStringValue()); 344 expect(myFavorites?.description).toBeUndefined(); 345 expect(myFavorites?.authorId).toBe(cardAuthorId.value); 346 }); 347 348 it('should calculate URL library count correctly', async () => { 349 // Create URL card 350 const url = URL.create('https://example.com/popular-article').unwrap(); 351 const urlCard1 = new CardBuilder() 352 .withCuratorId(cardAuthorId.value) 353 .withUrlCard(url) 354 .buildOrThrow(); 355 356 // Create another card with the same URL by different user 357 const urlCard2 = new CardBuilder() 358 .withCuratorId(actorId.value) 359 .withUrlCard(url) 360 .buildOrThrow(); 361 362 await cardRepository.save(urlCard1); 363 await cardRepository.save(urlCard2); 364 365 // Add both cards to their respective libraries 366 urlCard1.addToLibrary(cardAuthorId); 367 urlCard2.addToLibrary(actorId); 368 await cardRepository.save(urlCard1); 369 await cardRepository.save(urlCard2); 370 371 // Create notification for the first card 372 const notification = Notification.create({ 373 recipientUserId: recipientId, 374 actorUserId: actorId, 375 type: NotificationType.create( 376 NotificationTypeEnum.USER_ADDED_YOUR_CARD, 377 ).unwrap(), 378 metadata: { 379 cardId: urlCard1.cardId.getStringValue(), 380 }, 381 read: false, 382 createdAt: new Date(), 383 updatedAt: new Date(), 384 }).unwrap(); 385 386 await notificationRepository.save(notification); 387 388 const result = await notificationRepository.findByRecipientEnriched( 389 recipientId, 390 { page: 1, limit: 10 }, 391 ); 392 393 expect(result.isOk()).toBe(true); 394 const data = result.unwrap(); 395 expect(data.notifications).toHaveLength(1); 396 397 const enrichedNotification = data.notifications[0]!; 398 expect(enrichedNotification.cardLibraryCount).toBe(1); // Individual card library count 399 expect(enrichedNotification.cardUrlLibraryCount).toBe(2); // URL appears in 2 different users' libraries 400 }); 401 402 it('should handle pagination correctly', async () => { 403 // Create multiple URL cards and notifications 404 const notifications = []; 405 for (let i = 1; i <= 5; i++) { 406 const url = URL.create(`https://example.com/article-${i}`).unwrap(); 407 const urlCard = new CardBuilder() 408 .withCuratorId(cardAuthorId.value) 409 .withUrlCard(url) 410 .buildOrThrow(); 411 412 await cardRepository.save(urlCard); 413 414 const notification = Notification.create({ 415 recipientUserId: recipientId, 416 actorUserId: actorId, 417 type: NotificationType.create( 418 NotificationTypeEnum.USER_ADDED_YOUR_CARD, 419 ).unwrap(), 420 metadata: { 421 cardId: urlCard.cardId.getStringValue(), 422 }, 423 read: i % 2 === 0, // Make some read, some unread 424 createdAt: new Date(2024, 0, i), // Different dates for ordering 425 updatedAt: new Date(2024, 0, i), 426 }).unwrap(); 427 428 await notificationRepository.save(notification); 429 notifications.push(notification); 430 } 431 432 // Test first page 433 const page1Result = await notificationRepository.findByRecipientEnriched( 434 recipientId, 435 { page: 1, limit: 2 }, 436 ); 437 438 expect(page1Result.isOk()).toBe(true); 439 const page1Data = page1Result.unwrap(); 440 expect(page1Data.notifications).toHaveLength(2); 441 expect(page1Data.totalCount).toBe(5); 442 expect(page1Data.hasMore).toBe(true); 443 expect(page1Data.unreadCount).toBe(3); // Notifications 1, 3, 5 are unread 444 445 // Test second page 446 const page2Result = await notificationRepository.findByRecipientEnriched( 447 recipientId, 448 { page: 2, limit: 2 }, 449 ); 450 451 expect(page2Result.isOk()).toBe(true); 452 const page2Data = page2Result.unwrap(); 453 expect(page2Data.notifications).toHaveLength(2); 454 expect(page2Data.totalCount).toBe(5); 455 expect(page2Data.hasMore).toBe(true); 456 457 // Test last page 458 const page3Result = await notificationRepository.findByRecipientEnriched( 459 recipientId, 460 { page: 3, limit: 2 }, 461 ); 462 463 expect(page3Result.isOk()).toBe(true); 464 const page3Data = page3Result.unwrap(); 465 expect(page3Data.notifications).toHaveLength(1); 466 expect(page3Data.totalCount).toBe(5); 467 expect(page3Data.hasMore).toBe(false); 468 }); 469 470 it('should filter by unread only when requested', async () => { 471 // Create notifications with different read states 472 for (let i = 1; i <= 4; i++) { 473 const url = URL.create(`https://example.com/article-${i}`).unwrap(); 474 const urlCard = new CardBuilder() 475 .withCuratorId(cardAuthorId.value) 476 .withUrlCard(url) 477 .buildOrThrow(); 478 479 await cardRepository.save(urlCard); 480 481 const notification = Notification.create({ 482 recipientUserId: recipientId, 483 actorUserId: actorId, 484 type: NotificationType.create( 485 NotificationTypeEnum.USER_ADDED_YOUR_CARD, 486 ).unwrap(), 487 metadata: { 488 cardId: urlCard.cardId.getStringValue(), 489 }, 490 read: i <= 2, // First 2 are read, last 2 are unread 491 createdAt: new Date(2024, 0, i), 492 updatedAt: new Date(2024, 0, i), 493 }).unwrap(); 494 495 await notificationRepository.save(notification); 496 } 497 498 // Test all notifications 499 const allResult = await notificationRepository.findByRecipientEnriched( 500 recipientId, 501 { page: 1, limit: 10 }, 502 ); 503 504 expect(allResult.isOk()).toBe(true); 505 const allData = allResult.unwrap(); 506 expect(allData.notifications).toHaveLength(4); 507 expect(allData.totalCount).toBe(4); 508 expect(allData.unreadCount).toBe(2); 509 510 // Test unread only 511 const unreadResult = await notificationRepository.findByRecipientEnriched( 512 recipientId, 513 { page: 1, limit: 10, unreadOnly: true }, 514 ); 515 516 expect(unreadResult.isOk()).toBe(true); 517 const unreadData = unreadResult.unwrap(); 518 expect(unreadData.notifications).toHaveLength(2); 519 expect(unreadData.totalCount).toBe(2); 520 expect(unreadData.unreadCount).toBe(2); 521 522 // Verify all returned notifications are unread 523 unreadData.notifications.forEach((notification) => { 524 expect(notification.read).toBe(false); 525 }); 526 }); 527 528 it('should handle notifications with missing card data gracefully', async () => { 529 // Create notification with non-existent card ID 530 const notification = Notification.create({ 531 recipientUserId: recipientId, 532 actorUserId: actorId, 533 type: NotificationType.create( 534 NotificationTypeEnum.USER_ADDED_YOUR_CARD, 535 ).unwrap(), 536 metadata: { 537 cardId: new UniqueEntityID().toString(), // Non-existent card 538 }, 539 read: false, 540 createdAt: new Date(), 541 updatedAt: new Date(), 542 }).unwrap(); 543 544 await notificationRepository.save(notification); 545 546 const result = await notificationRepository.findByRecipientEnriched( 547 recipientId, 548 { page: 1, limit: 10 }, 549 ); 550 551 expect(result.isOk()).toBe(true); 552 const data = result.unwrap(); 553 // Should skip notifications with missing card data 554 expect(data.notifications).toHaveLength(0); 555 expect(data.totalCount).toBe(1); // Total count includes the notification 556 expect(data.unreadCount).toBe(1); 557 }); 558 559 it('should order notifications by creation date descending', async () => { 560 // Create notifications with different creation dates 561 const dates = [ 562 new Date('2024-01-15'), 563 new Date('2024-01-10'), 564 new Date('2024-01-20'), 565 new Date('2024-01-12'), 566 ]; 567 568 for (let i = 0; i < dates.length; i++) { 569 const url = URL.create(`https://example.com/article-${i}`).unwrap(); 570 const urlCard = new CardBuilder() 571 .withCuratorId(cardAuthorId.value) 572 .withUrlCard(url) 573 .buildOrThrow(); 574 575 await cardRepository.save(urlCard); 576 577 const notification = Notification.create({ 578 recipientUserId: recipientId, 579 actorUserId: actorId, 580 type: NotificationType.create( 581 NotificationTypeEnum.USER_ADDED_YOUR_CARD, 582 ).unwrap(), 583 metadata: { 584 cardId: urlCard.cardId.getStringValue(), 585 }, 586 read: false, 587 createdAt: dates[i]!, 588 updatedAt: dates[i]!, 589 }).unwrap(); 590 591 await notificationRepository.save(notification); 592 } 593 594 const result = await notificationRepository.findByRecipientEnriched( 595 recipientId, 596 { page: 1, limit: 10 }, 597 ); 598 599 expect(result.isOk()).toBe(true); 600 const data = result.unwrap(); 601 expect(data.notifications).toHaveLength(4); 602 603 // Should be ordered by creation date descending (newest first) 604 const createdDates = data.notifications.map((n) => n.createdAt.getTime()); 605 expect(createdDates).toEqual([ 606 new Date('2024-01-20').getTime(), 607 new Date('2024-01-15').getTime(), 608 new Date('2024-01-12').getTime(), 609 new Date('2024-01-10').getTime(), 610 ]); 611 }); 612 613 it('should only return notifications for the specified recipient', async () => { 614 const otherRecipientId = CuratorId.create( 615 'did:plc:otherrecipient', 616 ).unwrap(); 617 618 // Create notifications for different recipients 619 for (const recipient of [recipientId, otherRecipientId]) { 620 const url = URL.create( 621 `https://example.com/article-${recipient.value}`, 622 ).unwrap(); 623 const urlCard = new CardBuilder() 624 .withCuratorId(cardAuthorId.value) 625 .withUrlCard(url) 626 .buildOrThrow(); 627 628 await cardRepository.save(urlCard); 629 630 const notification = Notification.create({ 631 recipientUserId: recipient, 632 actorUserId: actorId, 633 type: NotificationType.create( 634 NotificationTypeEnum.USER_ADDED_YOUR_CARD, 635 ).unwrap(), 636 metadata: { 637 cardId: urlCard.cardId.getStringValue(), 638 }, 639 read: false, 640 createdAt: new Date(), 641 updatedAt: new Date(), 642 }).unwrap(); 643 644 await notificationRepository.save(notification); 645 } 646 647 // Query for specific recipient 648 const result = await notificationRepository.findByRecipientEnriched( 649 recipientId, 650 { page: 1, limit: 10 }, 651 ); 652 653 expect(result.isOk()).toBe(true); 654 const data = result.unwrap(); 655 expect(data.notifications).toHaveLength(1); 656 expect(data.totalCount).toBe(1); 657 expect(data.unreadCount).toBe(1); 658 659 // Verify it's the correct notification 660 expect(data.notifications[0]?.cardUrl).toBe( 661 `https://example.com/article-${recipientId.value}`, 662 ); 663 }); 664 }); 665});