diff --git a/docs/useCases.md b/docs/useCases.md index 44b25e02..bb3a2951 100644 --- a/docs/useCases.md +++ b/docs/useCases.md @@ -88,6 +88,11 @@ The different use cases currently available in the package are classified below, - [Get Application Terms of Use](#get-application-terms-of-use) - [Contact](#Contact) - [Send Feedback to Object Contacts](#send-feedback-to-object-contacts) +- [Notifications](#Notifications) + - [Get All Notifications by User](#get-all-notifications-by-user) + - [Delete Notification](#delete-notification) + - [Get Unread Count](#get-unread-count) + - [Mark As Read](#mark-as-read) - [Search](#Search) - [Get Search Services](#get-search-services) @@ -2096,6 +2101,92 @@ In ContactDTO, it takes the following information: - **body**: the email body to send. - **fromEmail**: the email to list in the reply-to field. +## Notifications + +#### Get All Notifications by User + +Returns a [Notification](../src/notifications/domain/models/Notification.ts) array containing all notifications for the current authenticated user. + +##### Example call: + +```typescript +import { getAllNotificationsByUser } from '@iqss/dataverse-client-javascript' + +/* ... */ + +getAllNotificationsByUser.execute().then((notifications: Notification[]) => { + /* ... */ +}) + +/* ... */ +``` + +_See [use case](../src/notifications/domain/useCases/GetAllNotificationsByUser.ts) implementation_. + +#### Delete Notification + +Deletes a specific notification for the current authenticated user by its ID. + +##### Example call: + +```typescript +import { deleteNotification } from '@iqss/dataverse-client-javascript' + +/* ... */ + +const notificationId = 123 + +deleteNotification.execute(notificationId: number).then(() => { + /* ... */ +}) + +/* ... */ +``` + +_See [use case](../src/notifications/domain/useCases/DeleteNotification.ts) implementation_. + +#### Get Unread Count + +Returns the number of unread notifications for the current authenticated user. + +##### Example call: + +```typescript +import { getUnreadNotificationsCount } from '@iqss/dataverse-client-javascript' + +/* ... */ + +getUnreadNotificationsCount.execute().then((count: number) => { + console.log(`You have ${count} unread notifications`) +}) + +/* ... */ +``` + +_See [use case](../src/notifications/domain/useCases/GetUnreadNotificationsCount.ts) implementation_. + +#### Mark As Read + +Marks a specific notification as read for the current authenticated user. This operation is idempotent - marking an already-read notification as read will not cause an error. + +##### Example call: + +```typescript +import { markNotificationAsRead } from '@iqss/dataverse-client-javascript' + +/* ... */ + +const notificationId = 123 + +markNotificationAsRead.execute(notificationId).then(() => { + console.log('Notification marked as read') +}) + +/* ... */ +``` + +_See [use case](../src/notifications/domain/useCases/MarkNotificationAsRead.ts) implementation_. + ## Search #### Get Search Services diff --git a/src/index.ts b/src/index.ts index 2fb70d9e..814abb34 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,4 +8,5 @@ export * from './collections' export * from './metadataBlocks' export * from './files' export * from './contactInfo' +export * from './notifications' export * from './search' diff --git a/src/notifications/domain/models/Notification.ts b/src/notifications/domain/models/Notification.ts new file mode 100644 index 00000000..001d0933 --- /dev/null +++ b/src/notifications/domain/models/Notification.ts @@ -0,0 +1,72 @@ +export enum NotificationType { + ASSIGNROLE = 'ASSIGNROLE', + REVOKEROLE = 'REVOKEROLE', + CREATEDV = 'CREATEDV', + CREATEDS = 'CREATEDS', + CREATEACC = 'CREATEACC', + SUBMITTEDDS = 'SUBMITTEDDS', + RETURNEDDS = 'RETURNEDDS', + PUBLISHEDDS = 'PUBLISHEDDS', + REQUESTFILEACCESS = 'REQUESTFILEACCESS', + GRANTFILEACCESS = 'GRANTFILEACCESS', + REJECTFILEACCESS = 'REJECTFILEACCESS', + FILESYSTEMIMPORT = 'FILESYSTEMIMPORT', + CHECKSUMIMPORT = 'CHECKSUMIMPORT', + CHECKSUMFAIL = 'CHECKSUMFAIL', + CONFIRMEMAIL = 'CONFIRMEMAIL', + APIGENERATED = 'APIGENERATED', + INGESTCOMPLETED = 'INGESTCOMPLETED', + INGESTCOMPLETEDWITHERRORS = 'INGESTCOMPLETEDWITHERRORS', + PUBLISHFAILED_PIDREG = 'PUBLISHFAILED_PIDREG', + WORKFLOW_SUCCESS = 'WORKFLOW_SUCCESS', + WORKFLOW_FAILURE = 'WORKFLOW_FAILURE', + STATUSUPDATED = 'STATUSUPDATED', + DATASETCREATED = 'DATASETCREATED', + DATASETMENTIONED = 'DATASETMENTIONED', + GLOBUSUPLOADCOMPLETED = 'GLOBUSUPLOADCOMPLETED', + GLOBUSUPLOADCOMPLETEDWITHERRORS = 'GLOBUSUPLOADCOMPLETEDWITHERRORS', + GLOBUSDOWNLOADCOMPLETED = 'GLOBUSDOWNLOADCOMPLETED', + GLOBUSDOWNLOADCOMPLETEDWITHERRORS = 'GLOBUSDOWNLOADCOMPLETEDWITHERRORS', + REQUESTEDFILEACCESS = 'REQUESTEDFILEACCESS', + GLOBUSUPLOADREMOTEFAILURE = 'GLOBUSUPLOADREMOTEFAILURE', + GLOBUSUPLOADLOCALFAILURE = 'GLOBUSUPLOADLOCALFAILURE', + PIDRECONCILED = 'PIDRECONCILED' +} + +export interface RoleAssignment { + id: number + assignee: string + definitionPointId: number + roleId: number + roleName: string + _roleAlias: string +} + +export interface Notification { + id: number + type: NotificationType + subjectText?: string + messageText?: string + sentTimestamp: string + displayAsRead: boolean + installationBrandName?: string + userGuidesBaseUrl?: string + userGuidesVersion?: string + userGuidesSectionPath?: string + roleAssignments?: RoleAssignment[] + collectionAlias?: string + collectionDisplayName?: string + datasetPersistentIdentifier?: string + datasetDisplayName?: string + ownerPersistentIdentifier?: string + ownerAlias?: string + ownerDisplayName?: string + requestorFirstName?: string + requestorLastName?: string + requestorEmail?: string + dataFileId?: number + dataFileDisplayName?: string + currentCurationStatus?: string + additionalInfo?: string + objectDeleted?: boolean +} diff --git a/src/notifications/domain/repositories/INotificationsRepository.ts b/src/notifications/domain/repositories/INotificationsRepository.ts new file mode 100644 index 00000000..9392c543 --- /dev/null +++ b/src/notifications/domain/repositories/INotificationsRepository.ts @@ -0,0 +1,8 @@ +import { Notification } from '../models/Notification' + +export interface INotificationsRepository { + getAllNotificationsByUser(inAppNotificationFormat?: boolean): Promise + deleteNotification(notificationId: number): Promise + getUnreadNotificationsCount(): Promise + markNotificationAsRead(notificationId: number): Promise +} diff --git a/src/notifications/domain/useCases/DeleteNotification.ts b/src/notifications/domain/useCases/DeleteNotification.ts new file mode 100644 index 00000000..ed57fc0b --- /dev/null +++ b/src/notifications/domain/useCases/DeleteNotification.ts @@ -0,0 +1,16 @@ +import { UseCase } from '../../../core/domain/useCases/UseCase' +import { INotificationsRepository } from '../repositories/INotificationsRepository' + +/** + * Use case for deleting a specific notification for the current user. + * + * @param notificationId - The ID of the notification to delete. + * @returns {Promise} - A promise that resolves when the notification is deleted. + */ +export class DeleteNotification implements UseCase { + constructor(private readonly notificationsRepository: INotificationsRepository) {} + + async execute(notificationId: number): Promise { + return this.notificationsRepository.deleteNotification(notificationId) + } +} diff --git a/src/notifications/domain/useCases/GetAllNotificationsByUser.ts b/src/notifications/domain/useCases/GetAllNotificationsByUser.ts new file mode 100644 index 00000000..43555ccc --- /dev/null +++ b/src/notifications/domain/useCases/GetAllNotificationsByUser.ts @@ -0,0 +1,19 @@ +import { UseCase } from '../../../core/domain/useCases/UseCase' +import { Notification } from '../models/Notification' +import { INotificationsRepository } from '../repositories/INotificationsRepository' + +export class GetAllNotificationsByUser implements UseCase { + constructor(private readonly notificationsRepository: INotificationsRepository) {} + + /** + * Use case for retrieving all notifications for the current user. + * + * @param inAppNotificationFormat - Optional parameter to retrieve fields needed for in-app notifications + * @returns {Promise} - A promise that resolves to an array of Notification instances. + */ + async execute(inAppNotificationFormat?: boolean): Promise { + return (await this.notificationsRepository.getAllNotificationsByUser( + inAppNotificationFormat + )) as Notification[] + } +} diff --git a/src/notifications/domain/useCases/GetUnreadNotificationsCount.ts b/src/notifications/domain/useCases/GetUnreadNotificationsCount.ts new file mode 100644 index 00000000..2e59c55e --- /dev/null +++ b/src/notifications/domain/useCases/GetUnreadNotificationsCount.ts @@ -0,0 +1,19 @@ +import { UseCase } from '../../../core/domain/useCases/UseCase' +import { INotificationsRepository } from '../repositories/INotificationsRepository' + +export class GetUnreadNotificationsCount implements UseCase { + private notificationsRepository: INotificationsRepository + + constructor(notificationsRepository: INotificationsRepository) { + this.notificationsRepository = notificationsRepository + } + + /** + * Use case for retrieving the number of unread notifications for the current user. + * + * @returns {Promise} - A promise that resolves to the number of unread notifications. + */ + async execute(): Promise { + return await this.notificationsRepository.getUnreadNotificationsCount() + } +} diff --git a/src/notifications/domain/useCases/MarkNotificationAsRead.ts b/src/notifications/domain/useCases/MarkNotificationAsRead.ts new file mode 100644 index 00000000..017be28c --- /dev/null +++ b/src/notifications/domain/useCases/MarkNotificationAsRead.ts @@ -0,0 +1,20 @@ +import { UseCase } from '../../../core/domain/useCases/UseCase' +import { INotificationsRepository } from '../repositories/INotificationsRepository' + +export class MarkNotificationAsRead implements UseCase { + private notificationsRepository: INotificationsRepository + + constructor(notificationsRepository: INotificationsRepository) { + this.notificationsRepository = notificationsRepository + } + + /** + * Use case for marking a notification as read. + * + * @param notificationId - The ID of the notification to mark as read. + * @returns {Promise} - A promise that resolves when the notification is marked as read. + */ + async execute(notificationId: number): Promise { + return await this.notificationsRepository.markNotificationAsRead(notificationId) + } +} diff --git a/src/notifications/index.ts b/src/notifications/index.ts new file mode 100644 index 00000000..3075ee90 --- /dev/null +++ b/src/notifications/index.ts @@ -0,0 +1,21 @@ +import { NotificationsRepository } from './infra/repositories/NotificationsRepository' +import { GetAllNotificationsByUser } from './domain/useCases/GetAllNotificationsByUser' +import { DeleteNotification } from './domain/useCases/DeleteNotification' +import { GetUnreadNotificationsCount } from './domain/useCases/GetUnreadNotificationsCount' +import { MarkNotificationAsRead } from './domain/useCases/MarkNotificationAsRead' + +const notificationsRepository = new NotificationsRepository() + +const getAllNotificationsByUser = new GetAllNotificationsByUser(notificationsRepository) +const deleteNotification = new DeleteNotification(notificationsRepository) +const getUnreadNotificationsCount = new GetUnreadNotificationsCount(notificationsRepository) +const markNotificationAsRead = new MarkNotificationAsRead(notificationsRepository) + +export { + getAllNotificationsByUser, + deleteNotification, + getUnreadNotificationsCount, + markNotificationAsRead +} + +export { Notification, NotificationType, RoleAssignment } from './domain/models/Notification' diff --git a/src/notifications/infra/repositories/NotificationsRepository.ts b/src/notifications/infra/repositories/NotificationsRepository.ts new file mode 100644 index 00000000..f310c34a --- /dev/null +++ b/src/notifications/infra/repositories/NotificationsRepository.ts @@ -0,0 +1,57 @@ +import { ApiRepository } from '../../../core/infra/repositories/ApiRepository' +import { INotificationsRepository } from '../../domain/repositories/INotificationsRepository' +import { Notification } from '../../domain/models/Notification' +import { NotificationPayload } from '../transformers/NotificationPayload' + +export class NotificationsRepository extends ApiRepository implements INotificationsRepository { + private readonly notificationsResourceName: string = 'notifications' + + public async getAllNotificationsByUser( + inAppNotificationFormat?: boolean + ): Promise { + const queryParams = inAppNotificationFormat ? { inAppNotificationFormat: 'true' } : undefined + return this.doGet( + this.buildApiEndpoint(this.notificationsResourceName, 'all'), + true, + queryParams + ) + .then((response) => { + const notifications = response.data.data.notifications + return notifications.map((notification: NotificationPayload) => { + const { dataverseDisplayName, dataverseAlias, ...restNotification } = notification + return { + ...restNotification, + ...(dataverseDisplayName && { collectionDisplayName: dataverseDisplayName }), + ...(dataverseAlias && { collectionAlias: dataverseAlias }) + } + }) as Notification[] + }) + .catch((error) => { + throw error + }) + } + + public async deleteNotification(notificationId: number): Promise { + return this.doDelete( + this.buildApiEndpoint(this.notificationsResourceName, notificationId.toString()) + ) + .then(() => undefined) + .catch((error) => { + throw error + }) + } + + public async getUnreadNotificationsCount(): Promise { + return this.doGet( + this.buildApiEndpoint(this.notificationsResourceName, 'unreadCount'), + true + ).then((response) => response.data.data.unreadCount as number) + } + + public async markNotificationAsRead(notificationId: number): Promise { + return this.doPut( + this.buildApiEndpoint(this.notificationsResourceName, 'markAsRead', notificationId), + {} + ).then(() => undefined) + } +} diff --git a/src/notifications/infra/transformers/NotificationPayload.ts b/src/notifications/infra/transformers/NotificationPayload.ts new file mode 100644 index 00000000..96d381ac --- /dev/null +++ b/src/notifications/infra/transformers/NotificationPayload.ts @@ -0,0 +1,30 @@ +import { RoleAssignment } from '../../domain/models/Notification' + +export interface NotificationPayload { + id: number + type: string + subjectText?: string + messageText?: string + sentTimestamp: string + displayAsRead: boolean + installationBrandName?: string + userGuidesBaseUrl?: string + userGuidesVersion?: string + userGuidesSectionPath?: string + roleAssignments?: RoleAssignment[] + dataverseAlias?: string + dataverseDisplayName?: string + datasetPersistentIdentifier?: string + datasetDisplayName?: string + ownerPersistentIdentifier?: string + ownerAlias?: string + ownerDisplayName?: string + requestorFirstName?: string + requestorLastName?: string + requestorEmail?: string + dataFileId?: number + dataFileDisplayName?: string + currentCurationStatus?: string + additionalInfo?: string + objectDeleted?: boolean +} diff --git a/test/functional/notifications/DeleteNotification.test.ts b/test/functional/notifications/DeleteNotification.test.ts new file mode 100644 index 00000000..093fa637 --- /dev/null +++ b/test/functional/notifications/DeleteNotification.test.ts @@ -0,0 +1,27 @@ +import { ApiConfig, deleteNotification, getAllNotificationsByUser, WriteError } from '../../../src' +import { TestConstants } from '../../testHelpers/TestConstants' +import { DataverseApiAuthMechanism } from '../../../src/core/infra/repositories/ApiConfig' + +describe('execute', () => { + beforeEach(async () => { + ApiConfig.init( + TestConstants.TEST_API_URL, + DataverseApiAuthMechanism.API_KEY, + process.env.TEST_API_KEY + ) + }) + + test('should successfully delete a notification for authenticated user', async () => { + const notifications = await getAllNotificationsByUser.execute() + const notificationId = notifications[notifications.length - 1].id + + await deleteNotification.execute(notificationId) + + const notificationsAfterDelete = await getAllNotificationsByUser.execute() + expect(notificationsAfterDelete.length).toBe(notifications.length - 1) + }) + + test('should throw an error when the notification id does not exist', async () => { + await expect(deleteNotification.execute(123)).rejects.toThrow(WriteError) + }) +}) diff --git a/test/functional/notifications/GetAllNotificationsByUser.test.ts b/test/functional/notifications/GetAllNotificationsByUser.test.ts new file mode 100644 index 00000000..7ccd7ec1 --- /dev/null +++ b/test/functional/notifications/GetAllNotificationsByUser.test.ts @@ -0,0 +1,37 @@ +import { ApiConfig, getAllNotificationsByUser, Notification } from '../../../src' +import { TestConstants } from '../../testHelpers/TestConstants' +import { DataverseApiAuthMechanism } from '../../../src/core/infra/repositories/ApiConfig' + +describe('execute', () => { + beforeEach(async () => { + ApiConfig.init( + TestConstants.TEST_API_URL, + DataverseApiAuthMechanism.API_KEY, + process.env.TEST_API_KEY + ) + }) + + test('should successfully return notifications for authenticated user', async () => { + const notifications: Notification[] = await getAllNotificationsByUser.execute() + + expect(notifications).not.toBeNull() + expect(Array.isArray(notifications)).toBe(true) + }) + + test('should have correct notification properties if notifications exist', async () => { + const notifications = await getAllNotificationsByUser.execute() + + expect(notifications[0]).toHaveProperty('id') + expect(notifications[0]).toHaveProperty('type') + expect(notifications[0]).toHaveProperty('sentTimestamp') + }) + + test('should have correct in-app notification properties when inAppNotificationFormat is true', async () => { + const notifications = await getAllNotificationsByUser.execute(true) + + expect(notifications[0]).toHaveProperty('id') + expect(notifications[0]).toHaveProperty('type') + expect(notifications[0]).toHaveProperty('sentTimestamp') + expect(notifications[0]).toHaveProperty('displayAsRead') + }) +}) diff --git a/test/integration/notifications/NotificationsRepository.test.ts b/test/integration/notifications/NotificationsRepository.test.ts new file mode 100644 index 00000000..5333e48d --- /dev/null +++ b/test/integration/notifications/NotificationsRepository.test.ts @@ -0,0 +1,186 @@ +import { + ApiConfig, + DataverseApiAuthMechanism +} from '../../../src/core/infra/repositories/ApiConfig' +import { TestConstants } from '../../testHelpers/TestConstants' +import { NotificationsRepository } from '../../../src/notifications/infra/repositories/NotificationsRepository' +import { + Notification, + NotificationType +} from '../../../src/notifications/domain/models/Notification' +import { createDataset, CreatedDatasetIdentifiers } from '../../../src/datasets' +import { publishDatasetViaApi, waitForNoLocks } from '../../testHelpers/datasets/datasetHelper' +import { WriteError } from '../../../src' +import { createCollection } from '../../../src/collections' +import { + createCollectionDTO, + deleteCollectionViaApi +} from '../../testHelpers/collections/collectionHelper' + +describe('NotificationsRepository', () => { + const sut: NotificationsRepository = new NotificationsRepository() + let testDatasetIds: CreatedDatasetIdentifiers + + beforeEach(() => { + ApiConfig.init( + TestConstants.TEST_API_URL, + DataverseApiAuthMechanism.API_KEY, + process.env.TEST_API_KEY + ) + }) + + test('should return notifications after creating and publishing a dataset', async () => { + // Create a dataset and publish it so that a notification of Dataset published is created + testDatasetIds = await createDataset.execute(TestConstants.TEST_NEW_DATASET_DTO) + + await publishDatasetViaApi(testDatasetIds.numericId) + await waitForNoLocks(testDatasetIds.numericId, 10) + + const notifications: Notification[] = await sut.getAllNotificationsByUser() + + expect(Array.isArray(notifications)).toBe(true) + expect(notifications.length).toBeGreaterThan(0) + + const publishedNotification = notifications.find( + (n) => n.type === NotificationType.PUBLISHEDDS + ) as Notification + + expect(publishedNotification).toBeDefined() + + expect(publishedNotification).toHaveProperty('id') + expect(publishedNotification).toHaveProperty('type') + expect(publishedNotification).toHaveProperty('subjectText') + expect(publishedNotification).toHaveProperty('messageText') + expect(publishedNotification).toHaveProperty('sentTimestamp') + + expect(publishedNotification?.subjectText).toContain( + 'Dataset created using the createDataset use case' + ) + expect(publishedNotification?.messageText).toContain( + 'Your dataset named Dataset created using the createDataset use case' + ) + }) + + test('should delete a notification by ID', async () => { + const notifications: Notification[] = await sut.getAllNotificationsByUser() + + const notificationToDelete = notifications[0] + + await sut.deleteNotification(notificationToDelete.id) + + const notificationsAfterDelete: Notification[] = await sut.getAllNotificationsByUser() + const deletedNotification = notificationsAfterDelete.find( + (n) => n.id === notificationToDelete.id + ) + expect(deletedNotification).toBeUndefined() + }) + + test('should throw error when trying to delete notification with wrong ID', async () => { + const nonExistentNotificationId = 99999 + + const expectedError = new WriteError( + `[404] Notification ${nonExistentNotificationId} not found.` + ) + + await expect(sut.deleteNotification(nonExistentNotificationId)).rejects.toThrow(expectedError) + }) + + test('should return notifications with basic properties when inAppNotificationFormat is true', async () => { + const notifications: Notification[] = await sut.getAllNotificationsByUser(true) + + const notification = notifications[0] + expect(notification).toHaveProperty('id') + expect(notification).toHaveProperty('type') + expect(notification).toHaveProperty('sentTimestamp') + expect(notification).toHaveProperty('displayAsRead') + }) + + test('should find notification with ASSIGNROLE type that has not been deleted', async () => { + const notifications: Notification[] = await sut.getAllNotificationsByUser(true) + + const assignRoleNotification = notifications.find( + (n) => n.type === NotificationType.ASSIGNROLE && !n.objectDeleted + ) + + expect(assignRoleNotification).toBeDefined() + expect(assignRoleNotification?.type).toBe(NotificationType.ASSIGNROLE) + expect(assignRoleNotification?.sentTimestamp).toBeDefined() + expect(assignRoleNotification?.displayAsRead).toBeDefined() + expect(assignRoleNotification?.collectionDisplayName).toBeDefined() + + expect(assignRoleNotification?.roleAssignments).toBeDefined() + expect(assignRoleNotification?.roleAssignments?.length).toBeGreaterThan(0) + expect(assignRoleNotification?.roleAssignments?.[0]).toHaveProperty('roleName') + expect(assignRoleNotification?.roleAssignments?.[0]).toHaveProperty('assignee') + expect(assignRoleNotification?.roleAssignments?.[0]).toHaveProperty('roleId') + expect(assignRoleNotification?.roleAssignments?.[0]).toHaveProperty('definitionPointId') + }) + + test('should create a collection and find the notification with CREATEDV type', async () => { + const testCollectionAlias = 'test-notification-collection' + const createdCollectionId = await createCollection.execute( + createCollectionDTO(testCollectionAlias) + ) + + expect(createdCollectionId).toBeDefined() + expect(createdCollectionId).toBeGreaterThan(0) + + const notifications: Notification[] = await sut.getAllNotificationsByUser(true) + expect(Array.isArray(notifications)).toBe(true) + expect(notifications.length).toBeGreaterThan(0) + + const createdvNotification = notifications.find( + (n) => n.collectionAlias === testCollectionAlias + ) + + expect(createdvNotification).toBeDefined() + expect(createdvNotification?.type).toBe(NotificationType.CREATEDV) + expect(createdvNotification?.collectionAlias).toBe(testCollectionAlias) + expect(createdvNotification?.sentTimestamp).toBeDefined() + expect(createdvNotification?.displayAsRead).toBe(false) + expect(createdvNotification?.collectionDisplayName).toBe('Test Collection') + expect(createdvNotification?.collectionAlias).toBe(testCollectionAlias) + + await deleteCollectionViaApi(testCollectionAlias) + }) + + test('should return array when inAppNotificationFormat is false', async () => { + const notifications: Notification[] = await sut.getAllNotificationsByUser(false) + + expect(Array.isArray(notifications)).toBe(true) + }) + + test('should return unread count', async () => { + const unreadCount = await sut.getUnreadNotificationsCount() + + expect(typeof unreadCount).toBe('number') + expect(unreadCount).toBeGreaterThanOrEqual(0) + }) + + test('should mark notification as read successfully', async () => { + const notifications: Notification[] = await sut.getAllNotificationsByUser() + + expect(notifications.length).toBeGreaterThan(0) + + const unreadNotification = notifications[0] + + await expect(sut.markNotificationAsRead(unreadNotification.id)).resolves.toBeUndefined() + + const updatedNotifications: Notification[] = await sut.getAllNotificationsByUser() + const updatedNotification = updatedNotifications.find((n) => n.id === unreadNotification.id) + + expect(updatedNotification?.displayAsRead).toBe(true) + }) + + test('should throw error when marking non-existent notification as read', async () => { + const nonExistentNotificationId = 99999 + + const expectedError = new WriteError( + `[404] Notification ${nonExistentNotificationId} not found.` + ) + + await expect(sut.markNotificationAsRead(nonExistentNotificationId)).rejects.toThrow( + expectedError + ) + }) +}) diff --git a/test/unit/notifications/DeleteNotification.test.ts b/test/unit/notifications/DeleteNotification.test.ts new file mode 100644 index 00000000..d568b975 --- /dev/null +++ b/test/unit/notifications/DeleteNotification.test.ts @@ -0,0 +1,49 @@ +import { DeleteNotification } from '../../../src/notifications/domain/useCases/DeleteNotification' +import { INotificationsRepository } from '../../../src/notifications/domain/repositories/INotificationsRepository' +import { + Notification, + NotificationType +} from '../../../src/notifications/domain/models/Notification' + +const mockNotifications: Notification[] = [ + { + id: 1, + type: NotificationType.PUBLISHEDDS, + subjectText: 'Test notification', + messageText: 'Test message', + sentTimestamp: '2025-01-01T00:00:00Z', + displayAsRead: false + }, + { + id: 2, + type: NotificationType.ASSIGNROLE, + subjectText: 'Role assignment', + messageText: 'Role assigned', + sentTimestamp: '2025-01-01T00:00:00Z', + displayAsRead: false + } +] + +describe('execute', () => { + test('should delete notification from repository', async () => { + const notificationsRepositoryStub: INotificationsRepository = {} as INotificationsRepository + notificationsRepositoryStub.getAllNotificationsByUser = jest.fn().mockResolvedValue([]) + notificationsRepositoryStub.deleteNotification = jest.fn().mockResolvedValue(mockNotifications) + const sut = new DeleteNotification(notificationsRepositoryStub) + + await sut.execute(123) + + expect(notificationsRepositoryStub.deleteNotification).toHaveBeenCalledWith(123) + }) + + test('should throw error when repository throws error', async () => { + const notificationsRepositoryStub: INotificationsRepository = {} as INotificationsRepository + notificationsRepositoryStub.getAllNotificationsByUser = jest.fn().mockResolvedValue([]) + notificationsRepositoryStub.deleteNotification = jest + .fn() + .mockRejectedValue(new Error('Repository error')) + const sut = new DeleteNotification(notificationsRepositoryStub) + + await expect(sut.execute(123)).rejects.toThrow('Repository error') + }) +}) diff --git a/test/unit/notifications/GetAllNotificationsByUser.test.ts b/test/unit/notifications/GetAllNotificationsByUser.test.ts new file mode 100644 index 00000000..7df67bca --- /dev/null +++ b/test/unit/notifications/GetAllNotificationsByUser.test.ts @@ -0,0 +1,49 @@ +import { GetAllNotificationsByUser } from '../../../src/notifications/domain/useCases/GetAllNotificationsByUser' +import { INotificationsRepository } from '../../../src/notifications/domain/repositories/INotificationsRepository' +import { + Notification, + NotificationType +} from '../../../src/notifications/domain/models/Notification' + +const mockNotifications: Notification[] = [ + { + id: 1, + type: NotificationType.PUBLISHEDDS, + subjectText: 'Test notification', + messageText: 'Test message', + sentTimestamp: '2025-01-01T00:00:00Z', + displayAsRead: false + }, + { + id: 2, + type: NotificationType.ASSIGNROLE, + subjectText: 'Role assignment', + messageText: 'Role assigned', + sentTimestamp: '2025-01-01T00:00:00Z', + displayAsRead: false + } +] + +describe('execute', () => { + test('should return notifications from repository', async () => { + const notificationsRepositoryStub: INotificationsRepository = {} as INotificationsRepository + notificationsRepositoryStub.getAllNotificationsByUser = jest + .fn() + .mockResolvedValue(mockNotifications) + const sut = new GetAllNotificationsByUser(notificationsRepositoryStub) + + const result = await sut.execute() + + expect(result).toEqual(mockNotifications) + }) + + test('should throw error when repository throws error', async () => { + const notificationsRepositoryStub: INotificationsRepository = {} as INotificationsRepository + notificationsRepositoryStub.getAllNotificationsByUser = jest + .fn() + .mockRejectedValue(new Error('Repository error')) + const sut = new GetAllNotificationsByUser(notificationsRepositoryStub) + + await expect(sut.execute()).rejects.toThrow('Repository error') + }) +}) diff --git a/test/unit/notifications/GetUnreadCount.test.ts b/test/unit/notifications/GetUnreadCount.test.ts new file mode 100644 index 00000000..7a54f9e9 --- /dev/null +++ b/test/unit/notifications/GetUnreadCount.test.ts @@ -0,0 +1,27 @@ +import { GetUnreadNotificationsCount } from '../../../src/notifications/domain/useCases/GetUnreadNotificationsCount' +import { INotificationsRepository } from '../../../src/notifications/domain/repositories/INotificationsRepository' +import { ReadError } from '../../../src' + +describe('GetUnreadNotificationsCount', () => { + test('should return unread count from repository', async () => { + const notificationsRepositoryStub: INotificationsRepository = {} as INotificationsRepository + + notificationsRepositoryStub.getUnreadNotificationsCount = jest.fn().mockResolvedValue(5) + const sut = new GetUnreadNotificationsCount(notificationsRepositoryStub) + + const result = await sut.execute() + + expect(notificationsRepositoryStub.getUnreadNotificationsCount).toHaveBeenCalledWith() + expect(result).toBe(5) + }) + + test('should throw error when repository throws error', async () => { + const notificationsRepositoryStub: INotificationsRepository = {} as INotificationsRepository + notificationsRepositoryStub.getUnreadNotificationsCount = jest + .fn() + .mockRejectedValue(new ReadError()) + const sut = new GetUnreadNotificationsCount(notificationsRepositoryStub) + + await expect(sut.execute()).rejects.toThrow(ReadError) + }) +}) diff --git a/test/unit/notifications/MarkAsRead.test.ts b/test/unit/notifications/MarkAsRead.test.ts new file mode 100644 index 00000000..a1f57a59 --- /dev/null +++ b/test/unit/notifications/MarkAsRead.test.ts @@ -0,0 +1,26 @@ +import { MarkNotificationAsRead } from '../../../src/notifications/domain/useCases/MarkNotificationAsRead' +import { INotificationsRepository } from '../../../src/notifications/domain/repositories/INotificationsRepository' +import { ReadError } from '../../../src' + +describe('MarkNotificationAsRead', () => { + test('should mark notification as read in repository', async () => { + const notificationsRepositoryStub: INotificationsRepository = {} as INotificationsRepository + + notificationsRepositoryStub.markNotificationAsRead = jest.fn().mockResolvedValue(undefined) + const sut = new MarkNotificationAsRead(notificationsRepositoryStub) + + await sut.execute(123) + + expect(notificationsRepositoryStub.markNotificationAsRead).toHaveBeenCalledWith(123) + }) + + test('should throw error when repository throws error', async () => { + const notificationsRepositoryStub: INotificationsRepository = {} as INotificationsRepository + notificationsRepositoryStub.markNotificationAsRead = jest + .fn() + .mockRejectedValue(new ReadError()) + const sut = new MarkNotificationAsRead(notificationsRepositoryStub) + + await expect(sut.execute(123)).rejects.toThrow(ReadError) + }) +})