Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions src/models/notification/notifications.models.ts
Comment thread
sarthak688 marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import type {
import type {
NotificationGetAllOptions,
NotificationGetResponse,
NotificationMarkAllReadResponse,
NotificationUpdateReadResponse,
} from './notifications.types';

/**
Expand Down Expand Up @@ -67,4 +69,51 @@ export interface NotificationServiceModel {
? PaginatedResponse<NotificationGetResponse>
: NonPaginatedResponse<NotificationGetResponse>
>;

/**
* 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('<tenantId>', ['<notificationId-1>', '<notificationId-2>']);
* ```
* @internal
*/
markAsRead(tenantId: string, notificationIds: string[]): Promise<NotificationUpdateReadResponse>;

/**
* 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('<tenantId>', ['<notificationId>']);
* ```
* @internal
*/
markAsUnread(tenantId: string, notificationIds: string[]): Promise<NotificationUpdateReadResponse>;

/**
* 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('<tenantId>');
* ```
* @internal
*/
markAllAsRead(tenantId: string): Promise<NotificationMarkAllReadResponse>;
}
19 changes: 19 additions & 0 deletions src/models/notification/notifications.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
}>;
79 changes: 76 additions & 3 deletions src/services/notification/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { BaseService } from '../base';
import type {
NotificationGetAllOptions,
NotificationGetResponse,
NotificationMarkAllReadResponse,
NotificationUpdateReadResponse,
} from '../../models/notification/notifications.types';
import type {
NotificationServiceModel,
Expand All @@ -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).
*
Comment on lines +40 to 41

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The class description was correctly trimmed to remove the stale "mark-read … land in follow-up PRs" sentence, but the first line is still incomplete now that this PR ships the mark-read methods.

Suggested change
* `/odata/v1/NotificationEntry` API).
*
* Provides inbox operations against the current user's notifications (the
* `/odata/v1/NotificationEntry` API).

Using the broader "inbox operations" keeps the description accurate through future PRs (delete, etc.) without needing another touch-up.

* Every public method takes the acting tenant GUID as the first argument — the
* notification API identifies the tenant via the `X-UIPATH-Internal-TenantId`
Expand Down Expand Up @@ -125,4 +126,76 @@ export class NotificationService extends BaseService implements NotificationServ
: NonPaginatedResponse<NotificationGetResponse>
>;
}

/**
* 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('<tenantId>', ['<notificationId-1>', '<notificationId-2>']);
* ```
* @internal
*/
@track('Notifications.MarkAsRead')
async markAsRead(tenantId: string, notificationIds: string[]): Promise<NotificationUpdateReadResponse> {
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('<tenantId>', ['<notificationId>']);
* ```
* @internal
*/
@track('Notifications.MarkAsUnread')
async markAsUnread(tenantId: string, notificationIds: string[]): Promise<NotificationUpdateReadResponse> {
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('<tenantId>');
* ```
* @internal
*/
@track('Notifications.MarkAllAsRead')
async markAllAsRead(tenantId: string): Promise<NotificationMarkAllReadResponse> {
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<NotificationUpdateReadResponse> {
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 } };
}
}
1 change: 1 addition & 0 deletions src/utils/constants/endpoints/notification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Comment thread
sarthak688 marked this conversation as resolved.
} as const;
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
}
});
});
});
85 changes: 85 additions & 0 deletions tests/unit/services/notification/notifications.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@

const result = await notificationService.getAll(NOTIFICATION_TEST_CONSTANTS.TENANT_ID);

expect(result.items.length).toBe(1);

Check warning on line 54 in tests/unit/services/notification/notifications.test.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer a more specific assertion instead of this generic one, e.g. "expect(result.items).toHaveLength(1)".

See more on https://sonarcloud.io/project/issues?id=UiPath_uipath-typescript&issues=AZ8hPgxPhsn6uFm4WsTG&open=AZ8hPgxPhsn6uFm4WsTG&pullRequest=513
expect(result.totalCount).toBe(1);

expect(PaginationHelpers.getAll).toHaveBeenCalledWith(
Expand Down Expand Up @@ -101,6 +101,91 @@
});
});

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();
Expand Down
4 changes: 4 additions & 0 deletions tests/utils/constants/notification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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;
Loading