This repository has no description
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});