diff --git a/src/models/notification/notifications.models.ts b/src/models/notification/notifications.models.ts index 9877a33b1..f97764818 100644 --- a/src/models/notification/notifications.models.ts +++ b/src/models/notification/notifications.models.ts @@ -12,6 +12,8 @@ import type { import type { NotificationGetAllOptions, NotificationGetResponse, + NotificationMarkAllReadResponse, + NotificationUpdateReadResponse, } from './notifications.types'; /** @@ -67,4 +69,51 @@ export interface NotificationServiceModel { ? PaginatedResponse : NonPaginatedResponse >; + + /** + * Marks the given notifications as read. + * + * @param tenantId - Tenant GUID + * @param notificationIds - GUIDs of notifications to mark read + * @returns Operation result echoing the affected IDs and new read state + * {@link NotificationUpdateReadResponse} + * + * @example + * ```typescript + * await notifications.markAsRead('', ['', '']); + * ``` + * @internal + */ + markAsRead(tenantId: string, notificationIds: string[]): Promise; + + /** + * Marks the given notifications as unread. + * + * @param tenantId - Tenant GUID + * @param notificationIds - GUIDs of notifications to mark unread + * @returns Operation result echoing the affected IDs and new read state + * {@link NotificationUpdateReadResponse} + * + * @example + * ```typescript + * await notifications.markAsUnread('', ['']); + * ``` + * @internal + */ + markAsUnread(tenantId: string, notificationIds: string[]): Promise; + + /** + * Marks all notifications in the current user's inbox as read. + * + * @param tenantId - Tenant GUID + * @returns Operation result confirming the bulk update + * {@link NotificationMarkAllReadResponse} + * + * @example + * ```typescript + * await notifications.markAllAsRead(''); + * ``` + * @internal + */ + markAllAsRead(tenantId: string): Promise; } diff --git a/src/models/notification/notifications.types.ts b/src/models/notification/notifications.types.ts index 1c3bc1f50..9d3291632 100644 --- a/src/models/notification/notifications.types.ts +++ b/src/models/notification/notifications.types.ts @@ -3,6 +3,7 @@ */ import type { PaginationOptions } from '../../utils/pagination/types'; +import type { OperationResponse } from '../common/types'; /** * Priority level assigned to a notification by the publisher. @@ -86,3 +87,21 @@ export type NotificationGetAllOptions = PaginationOptions & { filter?: string; orderby?: string; }; + +/** + * Response from `markAsRead()` / `markAsUnread()`. + * + * `notificationIds` echoes the IDs that were marked; `read` reflects the new state. + */ +export type NotificationUpdateReadResponse = OperationResponse<{ + notificationIds: string[]; + read: boolean; +}>; + +/** + * Response from `markAllAsRead()`. + */ +export type NotificationMarkAllReadResponse = OperationResponse<{ + all: true; + read: true; +}>; diff --git a/src/services/notification/notifications.ts b/src/services/notification/notifications.ts index 7f6e2b1f5..c87d8e9e4 100644 --- a/src/services/notification/notifications.ts +++ b/src/services/notification/notifications.ts @@ -8,6 +8,8 @@ import { BaseService } from '../base'; import type { NotificationGetAllOptions, NotificationGetResponse, + NotificationMarkAllReadResponse, + NotificationUpdateReadResponse, } from '../../models/notification/notifications.types'; import type { NotificationServiceModel, @@ -34,9 +36,8 @@ import { PaginationType } from '../../utils/pagination/internal-types'; /** * Service for interacting with the UiPath Notification inbox. * - * Provides list operations against the current user's notifications (the - * `/odata/v1/NotificationEntry` API). Further inbox operations (mark-read, - * delete) land in follow-up PRs. + * Provides inbox operations against the current user's notifications (the + * `/odata/v1/NotificationEntry` API). * * Every public method takes the acting tenant GUID as the first argument — the * notification API identifies the tenant via the `X-UIPATH-Internal-TenantId` @@ -125,4 +126,76 @@ export class NotificationService extends BaseService implements NotificationServ : NonPaginatedResponse >; } + + /** + * Marks the given notifications as read. + * + * @param tenantId - Tenant GUID + * @param notificationIds - GUIDs of notifications to mark read + * @returns Operation result echoing the affected IDs and new read state + * {@link NotificationUpdateReadResponse} + * + * @example + * ```typescript + * await notifications.markAsRead('', ['', '']); + * ``` + * @internal + */ + @track('Notifications.MarkAsRead') + async markAsRead(tenantId: string, notificationIds: string[]): Promise { + return this.updateRead(tenantId, notificationIds, true); + } + + /** + * Marks the given notifications as unread. + * + * @param tenantId - Tenant GUID + * @param notificationIds - GUIDs of notifications to mark unread + * @returns Operation result echoing the affected IDs and new read state + * {@link NotificationUpdateReadResponse} + * + * @example + * ```typescript + * await notifications.markAsUnread('', ['']); + * ``` + * @internal + */ + @track('Notifications.MarkAsUnread') + async markAsUnread(tenantId: string, notificationIds: string[]): Promise { + return this.updateRead(tenantId, notificationIds, false); + } + + /** + * Marks all notifications in the current user's inbox as read. + * + * @param tenantId - Tenant GUID + * @returns Operation result confirming the bulk update + * {@link NotificationMarkAllReadResponse} + * + * @example + * ```typescript + * await notifications.markAllAsRead(''); + * ``` + * @internal + */ + @track('Notifications.MarkAllAsRead') + async markAllAsRead(tenantId: string): Promise { + await this.post(NOTIFICATION_ENDPOINTS.UPDATE_READ, { + notifications: [], + forceAllRead: true, + }, { headers: createHeaders({ [TENANT_ID]: tenantId }) }); + return { success: true, data: { all: true, read: true } }; + } + + private async updateRead( + tenantId: string, + notificationIds: string[], + read: boolean + ): Promise { + await this.post(NOTIFICATION_ENDPOINTS.UPDATE_READ, { + notifications: notificationIds.map((notificationId) => ({ notificationId, read })), + forceAllRead: false, + }, { headers: createHeaders({ [TENANT_ID]: tenantId }) }); + return { success: true, data: { notificationIds, read } }; + } } diff --git a/src/utils/constants/endpoints/notification.ts b/src/utils/constants/endpoints/notification.ts index 4c97f529a..ab5c00a39 100644 --- a/src/utils/constants/endpoints/notification.ts +++ b/src/utils/constants/endpoints/notification.ts @@ -15,4 +15,5 @@ const NOTIFICATION_API_BASE = `${NOTIFICATION_BASE}/notificationserviceapi`; */ export const NOTIFICATION_ENDPOINTS = { GET_ALL: `${NOTIFICATION_API_BASE}/odata/v1/NotificationEntry`, + UPDATE_READ: `${NOTIFICATION_API_BASE}/odata/v1/NotificationEntry/UiPath.NotificationService.Api.UpdateRead`, } as const; diff --git a/tests/integration/shared/notification/notifications.integration.test.ts b/tests/integration/shared/notification/notifications.integration.test.ts index 3c4e7b036..94f85d4bc 100644 --- a/tests/integration/shared/notification/notifications.integration.test.ts +++ b/tests/integration/shared/notification/notifications.integration.test.ts @@ -121,4 +121,40 @@ describe.skip.each(modes)('Notifications - Integration Tests [%s]', (mode) => { expect(entry).not.toHaveProperty('partitionKey'); }); }); + + describe('mark-read flows', () => { + it('should mark a single notification as read and reflect the change via getAll', async () => { + const unread = await notifications.getAll(tenantId, { filter: 'hasRead eq false', pageSize: 1 }); + if (unread.items.length === 0) { + throw new Error( + 'No unread notifications in the inbox — cannot validate markAsRead. Trigger one on the test tenant.' + ); + } + const target = unread.items[0]; + + const mark = await notifications.markAsRead(tenantId, [target.id]); + expect(mark.success).toBe(true); + expect(mark.data.notificationIds).toEqual([target.id]); + expect(mark.data.read).toBe(true); + + // Restore so subsequent runs see the same fixture + const restore = await notifications.markAsUnread(tenantId, [target.id]); + expect(restore.success).toBe(true); + expect(restore.data.read).toBe(false); + }); + + it('markAllAsRead should succeed without per-id payload', async () => { + // Snapshot one unread notification before the bulk operation so we can restore inbox state. + const preSnapshot = await notifications.getAll(tenantId, { filter: 'hasRead eq false', pageSize: 1 }); + + const result = await notifications.markAllAsRead(tenantId); + expect(result.success).toBe(true); + expect(result.data).toEqual({ all: true, read: true }); + + // Restore so subsequent runs can still find unread notifications for the markAsRead test. + if (preSnapshot.items.length > 0) { + await notifications.markAsUnread(tenantId, [preSnapshot.items[0].id]); + } + }); + }); }); diff --git a/tests/unit/services/notification/notifications.test.ts b/tests/unit/services/notification/notifications.test.ts index ee8680d28..4841cd00f 100644 --- a/tests/unit/services/notification/notifications.test.ts +++ b/tests/unit/services/notification/notifications.test.ts @@ -101,6 +101,91 @@ describe('NotificationService Unit Tests', () => { }); }); + describe('markAsRead', () => { + it('should POST per-id read=true entries with tenant header', async () => { + mockApiClient.post.mockResolvedValue(undefined); + const ids = [ + NOTIFICATION_TEST_CONSTANTS.NOTIFICATION_ID, + NOTIFICATION_TEST_CONSTANTS.NOTIFICATION_ID_2, + ]; + + const result = await notificationService.markAsRead(NOTIFICATION_TEST_CONSTANTS.TENANT_ID, ids); + + expect(mockApiClient.post).toHaveBeenCalledWith( + NOTIFICATION_ENDPOINTS.UPDATE_READ, + { + notifications: [ + { notificationId: NOTIFICATION_TEST_CONSTANTS.NOTIFICATION_ID, read: true }, + { notificationId: NOTIFICATION_TEST_CONSTANTS.NOTIFICATION_ID_2, read: true }, + ], + forceAllRead: false, + }, + { headers: TENANT_HEADER } + ); + expect(result).toEqual({ success: true, data: { notificationIds: ids, read: true } }); + }); + + it('should propagate errors', async () => { + mockApiClient.post.mockRejectedValue(createMockError(NOTIFICATION_TEST_CONSTANTS.ERROR_NOTIFICATION_NOT_FOUND)); + + await expect( + notificationService.markAsRead(NOTIFICATION_TEST_CONSTANTS.TENANT_ID, [NOTIFICATION_TEST_CONSTANTS.NOTIFICATION_ID]) + ).rejects.toThrow(NOTIFICATION_TEST_CONSTANTS.ERROR_NOTIFICATION_NOT_FOUND); + }); + }); + + describe('markAsUnread', () => { + it('should POST per-id read=false entries with tenant header', async () => { + mockApiClient.post.mockResolvedValue(undefined); + const ids = [NOTIFICATION_TEST_CONSTANTS.NOTIFICATION_ID]; + + const result = await notificationService.markAsUnread(NOTIFICATION_TEST_CONSTANTS.TENANT_ID, ids); + + expect(mockApiClient.post).toHaveBeenCalledWith( + NOTIFICATION_ENDPOINTS.UPDATE_READ, + { + notifications: [ + { notificationId: NOTIFICATION_TEST_CONSTANTS.NOTIFICATION_ID, read: false }, + ], + forceAllRead: false, + }, + { headers: TENANT_HEADER } + ); + expect(result).toEqual({ success: true, data: { notificationIds: ids, read: false } }); + }); + + it('should propagate errors', async () => { + mockApiClient.post.mockRejectedValue(createMockError(NOTIFICATION_TEST_CONSTANTS.ERROR_NOTIFICATION_NOT_FOUND)); + + await expect( + notificationService.markAsUnread(NOTIFICATION_TEST_CONSTANTS.TENANT_ID, [NOTIFICATION_TEST_CONSTANTS.NOTIFICATION_ID]) + ).rejects.toThrow(NOTIFICATION_TEST_CONSTANTS.ERROR_NOTIFICATION_NOT_FOUND); + }); + }); + + describe('markAllAsRead', () => { + it('should POST forceAllRead=true with empty notifications array and tenant header', async () => { + mockApiClient.post.mockResolvedValue(undefined); + + const result = await notificationService.markAllAsRead(NOTIFICATION_TEST_CONSTANTS.TENANT_ID); + + expect(mockApiClient.post).toHaveBeenCalledWith( + NOTIFICATION_ENDPOINTS.UPDATE_READ, + { notifications: [], forceAllRead: true }, + { headers: TENANT_HEADER } + ); + expect(result).toEqual({ success: true, data: { all: true, read: true } }); + }); + + it('should propagate errors', async () => { + mockApiClient.post.mockRejectedValue(createMockError(NOTIFICATION_TEST_CONSTANTS.ERROR_NOTIFICATION_NOT_FOUND)); + + await expect( + notificationService.markAllAsRead(NOTIFICATION_TEST_CONSTANTS.TENANT_ID) + ).rejects.toThrow(NOTIFICATION_TEST_CONSTANTS.ERROR_NOTIFICATION_NOT_FOUND); + }); + }); + describe('response transformation', () => { it('strips internal fields and renames isRead → hasRead in the returned notifications', async () => { const raw: RawNotificationEntry = createBasicNotificationEntry(); diff --git a/tests/utils/constants/notification.ts b/tests/utils/constants/notification.ts index aee905759..8acea5884 100644 --- a/tests/utils/constants/notification.ts +++ b/tests/utils/constants/notification.ts @@ -9,6 +9,7 @@ export const NOTIFICATION_TEST_CONSTANTS = { // Notification entry identifiers NOTIFICATION_ID: '11111111-1111-4111-8111-111111111111', + NOTIFICATION_ID_2: '22222222-2222-4222-8222-222222222222', // Publisher / topic IDs used inside notification-entry fixtures PUBLISHER_ID: '44444444-4444-4444-4444-444444444444', @@ -27,4 +28,7 @@ export const NOTIFICATION_TEST_CONSTANTS = { // Unix epoch seconds (API returns seconds, not ms) PUBLISHED_ON: 1780981200, + + // Error messages + ERROR_NOTIFICATION_NOT_FOUND: 'Notification not found', } as const;