From f433f25b85094301a813e3ec055b36a9dad5c610 Mon Sep 17 00:00:00 2001 From: sarthak688 <107241313+sarthak688@users.noreply.github.com> Date: Tue, 9 Jun 2026 11:22:42 +0530 Subject: [PATCH 1/2] feat(notifications): add Notifications and Subscriptions services Co-Authored-By: claude[bot] <209825114+claude[bot]@users.noreply.github.com> Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/oauth-scopes.md | 25 ++ docs/pagination.md | 1 + mkdocs.yml | 3 + package.json | 10 + rollup.config.js | 5 + src/models/notification/index.ts | 8 + .../notifications.internal-types.ts | 37 ++ .../notification/notifications.models.ts | 172 ++++++++ .../notification/notifications.types.ts | 107 +++++ .../notification/subscriptions.models.ts | 266 ++++++++++++ .../notification/subscriptions.types.ts | 220 ++++++++++ src/services/notification/index.ts | 33 ++ src/services/notification/notifications.ts | 231 +++++++++++ src/services/notification/subscriptions.ts | 295 ++++++++++++++ src/utils/constants/endpoints/base.ts | 11 + src/utils/constants/endpoints/index.ts | 3 + src/utils/constants/endpoints/notification.ts | 39 ++ tests/integration/config/unified-setup.ts | 5 + .../notifications.integration.test.ts | 108 +++++ .../subscriptions.integration.test.ts | 135 +++++++ .../notification/notifications.test.ts | 257 ++++++++++++ .../notification/subscriptions.test.ts | 377 ++++++++++++++++++ tests/utils/constants/index.ts | 1 + tests/utils/constants/notification.ts | 38 ++ tests/utils/mocks/index.ts | 1 + tests/utils/mocks/notification.ts | 109 +++++ 26 files changed, 2497 insertions(+) create mode 100644 src/models/notification/index.ts create mode 100644 src/models/notification/notifications.internal-types.ts create mode 100644 src/models/notification/notifications.models.ts create mode 100644 src/models/notification/notifications.types.ts create mode 100644 src/models/notification/subscriptions.models.ts create mode 100644 src/models/notification/subscriptions.types.ts create mode 100644 src/services/notification/index.ts create mode 100644 src/services/notification/notifications.ts create mode 100644 src/services/notification/subscriptions.ts create mode 100644 src/utils/constants/endpoints/notification.ts create mode 100644 tests/integration/shared/notification/notifications.integration.test.ts create mode 100644 tests/integration/shared/notification/subscriptions.integration.test.ts create mode 100644 tests/unit/services/notification/notifications.test.ts create mode 100644 tests/unit/services/notification/subscriptions.test.ts create mode 100644 tests/utils/constants/notification.ts create mode 100644 tests/utils/mocks/notification.ts diff --git a/docs/oauth-scopes.md b/docs/oauth-scopes.md index d614b4929..61cc67f42 100644 --- a/docs/oauth-scopes.md +++ b/docs/oauth-scopes.md @@ -201,6 +201,31 @@ The `ConversationalAgents` scope is required for real-time WebSocket sessions (` | `getPolicyTraces()` | `Insights.RealTimeData Insights OR.Folders.Read` | | `getOperationSummary()` | `Insights.RealTimeData Insights OR.Folders.Read` | +## Notifications + +| Method | OAuth Scope | +|--------|-------------| +| `getAll()` | `NotificationService` | +| `markRead()` | `NotificationService` | +| `markUnread()` | `NotificationService` | +| `markAllRead()` | `NotificationService` | +| `deleteNotifications()` | `NotificationService` | +| `deleteAll()` | `NotificationService` | + +## Subscriptions + +| Method | OAuth Scope | +|--------|-------------| +| `getAll()` | `NotificationService` | +| `getPublishers()` | `NotificationService` | +| `getSupportedChannels()` | `NotificationService` | +| `updateTopic()` | `NotificationService` | +| `updateCategory()` | `NotificationService` | +| `updatePublisher()` | `NotificationService` | +| `updateTopicGroup()` | `NotificationService` | +| `updateMode()` | `NotificationService` | +| `reset()` | `NotificationService` | + ## Processes | Method | OAuth Scope | diff --git a/docs/pagination.md b/docs/pagination.md index 8edbd6cf7..8f7427a76 100644 --- a/docs/pagination.md +++ b/docs/pagination.md @@ -137,3 +137,4 @@ console.log(`Total count: ${allAssets.totalCount}`); | Traces | `getById()` | ❌ No | | Traces | `getSpansByIds()` | ❌ No | | Governance | `getPolicyTraces()` | ✅ Yes | +| Notifications | `getAll()` | ✅ Yes | diff --git a/mkdocs.yml b/mkdocs.yml index ba5de4b3e..0b1264ccc 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -202,6 +202,9 @@ nav: - Cases: api/interfaces/CasesServiceModel.md - Case Instances: api/interfaces/CaseInstancesServiceModel.md - Jobs: api/interfaces/JobServiceModel.md + - Notifications: + - Inbox: api/interfaces/NotificationServiceModel.md + - Subscriptions: api/interfaces/SubscriptionServiceModel.md - Processes: api/interfaces/ProcessServiceModel.md - Queues: api/interfaces/QueueServiceModel.md - Tasks: api/interfaces/TaskServiceModel.md diff --git a/package.json b/package.json index 828652ade..099505487 100644 --- a/package.json +++ b/package.json @@ -185,6 +185,16 @@ "types": "./dist/governance/index.d.ts", "default": "./dist/governance/index.cjs" } + }, + "./notifications": { + "import": { + "types": "./dist/notifications/index.d.ts", + "default": "./dist/notifications/index.mjs" + }, + "require": { + "types": "./dist/notifications/index.d.ts", + "default": "./dist/notifications/index.cjs" + } } }, "files": [ diff --git a/rollup.config.js b/rollup.config.js index 3efd3fdb0..23e6432f6 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -213,6 +213,11 @@ const serviceEntries = [ name: 'governance', input: 'src/services/governance/index.ts', output: 'governance/index' + }, + { + name: 'notifications', + input: 'src/services/notification/index.ts', + output: 'notifications/index' } ]; diff --git a/src/models/notification/index.ts b/src/models/notification/index.ts new file mode 100644 index 000000000..5901cbf75 --- /dev/null +++ b/src/models/notification/index.ts @@ -0,0 +1,8 @@ +/** + * Notification & Subscription models barrel export. + */ + +export * from './notifications.types'; +export * from './notifications.models'; +export * from './subscriptions.types'; +export * from './subscriptions.models'; diff --git a/src/models/notification/notifications.internal-types.ts b/src/models/notification/notifications.internal-types.ts new file mode 100644 index 000000000..c918f045c --- /dev/null +++ b/src/models/notification/notifications.internal-types.ts @@ -0,0 +1,37 @@ +/** + * Internal-only types for the Notification service. + * + * NOT exported from the public barrel (`src/models/notification/index.ts`). + */ + +import type { NotificationGetResponse } from './notifications.types'; + +/** + * Raw notification entry shape as returned by `/odata/v1/NotificationEntry` — includes + * the internal/transport-layer fields the SDK drops before returning to consumers. + */ +export interface RawNotificationEntry extends NotificationGetResponse { + entityOrgName?: string | null; + entityTenantName?: string | null; + serviceRegistryName?: string | null; + messageTemplateKey?: string | null; + messageVersion?: number; + publicationId?: string; + correlationId?: string | null; + partitionKey?: string; +} + +/** + * Fields stripped from each {@link RawNotificationEntry} before it is returned to the + * SDK consumer as a {@link NotificationGetResponse}. + */ +export const INTERNAL_NOTIFICATION_FIELDS: ReadonlyArray = [ + 'entityOrgName', + 'entityTenantName', + 'serviceRegistryName', + 'messageTemplateKey', + 'messageVersion', + 'publicationId', + 'correlationId', + 'partitionKey', +]; diff --git a/src/models/notification/notifications.models.ts b/src/models/notification/notifications.models.ts new file mode 100644 index 000000000..a2525a326 --- /dev/null +++ b/src/models/notification/notifications.models.ts @@ -0,0 +1,172 @@ +/** + * Notification service model — public response shapes and the ServiceModel interface + * that drives generated API documentation. + */ + +import type { OperationResponse } from '../common/types'; +import type { + HasPaginationOptions, + NonPaginatedResponse, + PaginatedResponse, +} from '../../utils/pagination/types'; + +import type { + NotificationGetAllOptions, + NotificationGetResponse, +} from './notifications.types'; + +/** + * Response from `markRead()` / `markUnread()`. + * + * `notificationIds` echoes the IDs that were marked; `read` reflects the new state. + */ +export type NotificationUpdateReadResponse = OperationResponse<{ + notificationIds: string[]; + read: boolean; +}>; + +/** + * Response from `markAllRead()`. + */ +export type NotificationMarkAllReadResponse = OperationResponse<{ + all: true; + read: true; +}>; + +/** + * Response from `deleteNotifications()`. + * + * `notificationIds` echoes the IDs that were deleted. + */ +export type NotificationDeleteResponse = OperationResponse<{ + notificationIds: string[]; +}>; + +/** + * Response from `deleteAll()`. + */ +export type NotificationDeleteAllResponse = OperationResponse<{ + all: true; +}>; + +/** + * Public surface of the Notifications service. JSDoc on this interface drives + * the generated API reference documentation. + */ +export interface NotificationServiceModel { + /** + * Lists notifications from the current user's inbox. + * + * Returns the full list when no pagination params are provided, or a paginated cursor result + * when any of `pageSize`/`cursor`/`jumpToPage` is supplied. Supports OData `filter` and + * `orderby` query options. + * + * @param options - Optional OData query and pagination options + * @returns Array of notifications, or a paginated result when pagination params are supplied + * {@link NotificationGetResponse} + * + * @example Basic usage + * ```typescript + * import { Notifications } from '@uipath/uipath-typescript/notifications'; + * + * const notifications = new Notifications(sdk); + * const all = await notifications.getAll(); + * ``` + * + * @example Filter unread, most recent first + * ```typescript + * const unread = await notifications.getAll({ + * filter: 'isRead eq false', + * orderby: 'publishedOn desc', + * }); + * ``` + * + * @example First page with pagination + * ```typescript + * const page1 = await notifications.getAll({ pageSize: 20 }); + * if (page1.hasNextPage) { + * const page2 = await notifications.getAll({ cursor: page1.nextCursor }); + * } + * ``` + */ + getAll( + options?: T + ): Promise< + T extends HasPaginationOptions + ? PaginatedResponse + : NonPaginatedResponse + >; + + /** + * Marks the given notifications as read. + * + * @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.markRead(['', '']); + * ``` + */ + markRead(notificationIds: string[]): Promise; + + /** + * Marks the given notifications as unread. + * + * @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.markUnread(['']); + * ``` + */ + markUnread(notificationIds: string[]): Promise; + + /** + * Marks all notifications in the current user's inbox as read. + * + * Uses the server-side `forceAllRead` flag — no per-notification IDs are sent. + * + * @returns Operation result confirming the bulk update + * {@link NotificationMarkAllReadResponse} + * + * @example + * ```typescript + * await notifications.markAllRead(); + * ``` + */ + markAllRead(): Promise; + + /** + * Deletes the given notifications. + * + * @param notificationIds - GUIDs of notifications to delete. Must be non-empty — + * the server rejects an empty array with HTTP 400. + * @returns Operation result echoing the deleted IDs + * {@link NotificationDeleteResponse} + * + * @example + * ```typescript + * await notifications.deleteNotifications(['', '']); + * ``` + */ + deleteNotifications(notificationIds: string[]): Promise; + + /** + * Deletes all notifications from the current user's inbox. + * + * Uses the server-side `deleteAll` flag — no per-notification IDs are sent. + * + * @returns Operation result confirming the bulk delete + * {@link NotificationDeleteAllResponse} + * + * @example + * ```typescript + * await notifications.deleteAll(); + * ``` + */ + deleteAll(): Promise; +} diff --git a/src/models/notification/notifications.types.ts b/src/models/notification/notifications.types.ts new file mode 100644 index 000000000..ab69f28eb --- /dev/null +++ b/src/models/notification/notifications.types.ts @@ -0,0 +1,107 @@ +/** + * Notification inbox types — raw API shapes and request/response options. + */ + +import type { PaginationOptions } from '../../utils/pagination/types'; + +/** + * Priority level assigned to a notification by the publisher. + */ +export enum NotificationPriority { + Low = 'Low', + Medium = 'Medium', + High = 'High', + Critical = 'Critical', +} + +/** + * Severity classification of a notification topic. + */ +export enum NotificationCategory { + /** Informational — no action required. */ + Info = 'Info', + /** Successful operation completed. */ + Success = 'Success', + /** Warning — degraded behaviour or non-fatal issue. */ + Warn = 'Warn', + /** Error — operation failed but the system continues. */ + Error = 'Error', + /** Fatal — unrecoverable failure. */ + Fatal = 'Fatal', +} + +/** + * Notification delivery channel. + * + * `InApp` is always implicitly enabled (it is not returned by `getSupportedChannels()`); + * the others must be checked via `Subscriptions.getSupportedChannels()`. + */ +export enum NotificationMode { + /** Real-time in-app push (SignalR WebSocket). Always available. */ + InApp = 'InApp', + /** Email delivery. */ + Email = 'Email', + /** Slack delivery via Integration Service. */ + Slack = 'Slack', + /** Microsoft Teams delivery via Integration Service. */ + Teams = 'Teams', +} + +/** + * Notification entry as returned by `GET /odata/v1/NotificationEntry`. + * + * Field selection: many internal/transport-layer fields (`partitionKey`, `correlationId`, + * `publicationId`, `messageVersion`, `messageTemplateKey`, `serviceRegistryName`, + * `entityOrgName`, `entityTenantName`) are returned by the API but dropped from the SDK + * because they have no use for an application developer. + */ +export interface NotificationGetResponse { + /** Notification GUID. */ + id: string; + /** Resolved notification message text. */ + message: string | null; + /** Whether the user has read this notification. */ + isRead: boolean; + /** Name of the publisher (e.g. `Orchestrator`, `Actions`). */ + publisherName: string; + /** Publisher GUID. */ + publisherId: string; + /** Human-readable topic name. */ + topicName: string; + /** Stable topic identifier (e.g. `Process.JobExecution.Faulted`). */ + topicKeyName: string; + /** Topic GUID. */ + topicId: string; + /** GUID of the user this notification belongs to (returned uppercase by the API). */ + userId: string; + /** Email of the user. Often `null`. */ + userEmail: string | null; + /** Tenant GUID this notification belongs to. Often `null` for org-scoped notifications. */ + tenantId: string | null; + /** Notification priority. */ + priority: NotificationPriority; + /** Notification severity category. */ + category: NotificationCategory; + /** JSON string of template parameters — parse with `JSON.parse()`. May be `null`. */ + messageParam: string | null; + /** URL to navigate to when the notification is clicked. */ + redirectionUrl: string | null; + /** Unix epoch **seconds** when the notification was published. */ + publishedOn: number; +} + +/** + * Options for `Notifications.getAll()`. + * + * Supports OData query options (`filter`, `orderby`) and SDK cursor pagination. + * + * Notes: + * - `$select` and `$expand` are not exposed because the API returns 500 on `$select` + * and there are no expandable relationships on this endpoint. + */ +export type NotificationGetAllOptions = { + /** OData `$filter` expression (e.g. `"isRead eq false"`). */ + filter?: string; + /** OData `$orderby` expression (e.g. `"publishedOn desc"`). */ + orderby?: string; +} & PaginationOptions; diff --git a/src/models/notification/subscriptions.models.ts b/src/models/notification/subscriptions.models.ts new file mode 100644 index 000000000..6f80ea0a8 --- /dev/null +++ b/src/models/notification/subscriptions.models.ts @@ -0,0 +1,266 @@ +/** + * Subscription service model — public response shapes and the ServiceModel interface + * that drives generated API documentation. + */ + +import type { OperationResponse } from '../common/types'; + +import type { + AllowedMode, + CategorySubscriptionUpdate, + PublisherSubscriptionUpdate, + SubscriptionGetAllOptions, + SubscriptionGetPublishersOptions, + SubscriptionPublisher, + SupportedChannel, + TopicGroupSubscriptionUpdate, + TopicSubscriptionUpdate, +} from './subscriptions.types'; + +/** + * Response from `getAll()`, `getPublishers()` and `reset()` — a list of publishers + * with their topics, channels, and subscription state. + * + * Note: when returned from `getPublishers()`, publisher and topic objects only carry + * identity/discovery fields (no subscription-state). Use `getAll()` to inspect state. + */ +export interface SubscriptionGetResponse { + /** Publishers with their topics and subscription state. */ + publishers: SubscriptionPublisher[]; +} + +/** + * Response from `getSupportedChannels()`. + */ +export interface SubscriptionGetSupportedChannelsResponse { + /** Notification channels supported in the current tenant. `InApp` is not listed — it is always available. */ + channels: SupportedChannel[]; +} + +/** Response from `updateTopic()`. */ +export type SubscriptionUpdateTopicResponse = OperationResponse<{ + subscriptions: TopicSubscriptionUpdate[]; +}>; + +/** Response from `updateCategory()`. */ +export type SubscriptionUpdateCategoryResponse = OperationResponse<{ + subscriptions: CategorySubscriptionUpdate[]; +}>; + +/** Response from `updatePublisher()`. */ +export type SubscriptionUpdatePublisherResponse = OperationResponse<{ + subscriptions: PublisherSubscriptionUpdate[]; +}>; + +/** Response from `updateTopicGroup()`. */ +export type SubscriptionUpdateTopicGroupResponse = OperationResponse<{ + subscriptions: TopicGroupSubscriptionUpdate[]; +}>; + +/** Response from `updateMode()`. */ +export type SubscriptionUpdateModeResponse = OperationResponse<{ + publisherId: string; + mode: AllowedMode; +}>; + +/** + * Public surface of the Subscriptions service. JSDoc on this interface drives + * the generated API reference documentation. + */ +export interface SubscriptionServiceModel { + /** + * Gets the current user's subscription preferences, optionally filtered to a set of + * publisher names. + * + * Returns the full list of publishers (their topics, channels, and current subscription + * state) the user has access to. Use {@link SubscriptionGetAllOptions.publishers} to + * narrow to specific publishers. + * + * @param options - Optional publisher-name filter + * @returns Full subscription state for the matched publishers + * {@link SubscriptionGetResponse} + * + * @example Basic usage + * ```typescript + * import { Subscriptions } from '@uipath/uipath-typescript/notifications'; + * + * const subscriptions = new Subscriptions(sdk); + * const { publishers } = await subscriptions.getAll(); + * ``` + * + * @example Filter to specific publishers + * ```typescript + * const { publishers } = await subscriptions.getAll({ + * publishers: ['Orchestrator', 'Actions'], + * }); + * ``` + */ + getAll(options?: SubscriptionGetAllOptions): Promise; + + /** + * Lists available publishers and their topics, regardless of the user's current subscription. + * + * Used for discovery — pair with {@link getAll} to inspect what's subscribable, then + * call {@link updateTopic}, {@link updatePublisher}, or {@link updateMode} to change preferences. + * + * Note: the response from this endpoint carries only identity/discovery fields on + * publishers and topics (no subscription state). Use {@link getAll} to inspect state. + * + * @param options - Optional publisher-name filter + * @returns Publishers and their full topic catalogue + * {@link SubscriptionGetResponse} + * + * @example Basic usage + * ```typescript + * const { publishers } = await subscriptions.getPublishers(); + * ``` + * + * @example Filter to a single publisher + * ```typescript + * const { publishers } = await subscriptions.getPublishers({ name: 'Orchestrator' }); + * ``` + */ + getPublishers(options?: SubscriptionGetPublishersOptions): Promise; + + /** + * Gets the notification channels supported in the current tenant. + * + * Check the `isEnabled` field on each {@link SupportedChannel} before attempting to subscribe to a + * channel via {@link updateMode} — disabled channels will be rejected by the server. + * + * Note: `InApp` is always available and is not included in the response. + * + * @returns Supported channels with enabled status + * {@link SubscriptionGetSupportedChannelsResponse} + * + * @example + * ```typescript + * const { channels } = await subscriptions.getSupportedChannels(); + * const slack = channels.find(c => c.name === 'Slack'); + * if (slack?.isEnabled) { + * // safe to subscribe to Slack + * } + * ``` + */ + getSupportedChannels(): Promise; + + /** + * Updates topic-level subscription preferences. Each entry sets the subscription state + * (`isSubscribed`) for a single (topic, mode) pair. + * + * @param subscriptions - Topic subscription updates + * @returns Operation result echoing the submitted updates + * {@link SubscriptionUpdateTopicResponse} + * + * @example Unsubscribe a topic from a single channel + * ```typescript + * import { NotificationMode } from '@uipath/uipath-typescript/notifications'; + * + * await subscriptions.updateTopic([ + * { topicId: '', isSubscribed: false, notificationMode: NotificationMode.Email }, + * ]); + * ``` + */ + updateTopic(subscriptions: TopicSubscriptionUpdate[]): Promise; + + /** + * Updates category-level subscription preferences for a publisher. Each entry sets + * the subscription state for all topics of a given category via a given mode. + * + * @param subscriptions - Category subscription updates + * @returns Operation result echoing the submitted updates + * {@link SubscriptionUpdateCategoryResponse} + * + * @example Unsubscribe from all `Error` topics via email for one publisher + * ```typescript + * import { NotificationCategory, NotificationMode } from '@uipath/uipath-typescript/notifications'; + * + * await subscriptions.updateCategory([ + * { + * publisherId: '', + * category: NotificationCategory.Error, + * isSubscribed: false, + * notificationMode: NotificationMode.Email, + * }, + * ]); + * ``` + */ + updateCategory(subscriptions: CategorySubscriptionUpdate[]): Promise; + + /** + * Updates publisher-level opt-in / opt-out. Each entry toggles the user's overall + * opt-in for a publisher and optionally scopes the change to specific entities. + * + * @param subscriptions - Publisher subscription updates + * @returns Operation result echoing the submitted updates + * {@link SubscriptionUpdatePublisherResponse} + * + * @example Opt out of a publisher entirely + * ```typescript + * await subscriptions.updatePublisher([ + * { publisherId: '', isUserOptIn: false }, + * ]); + * ``` + */ + updatePublisher(subscriptions: PublisherSubscriptionUpdate[]): Promise; + + /** + * Updates topic-group subscription preferences. Each entry scopes a topic group to + * a specific set of entities. + * + * @param subscriptions - Topic-group subscription updates + * @returns Operation result echoing the submitted updates + * {@link SubscriptionUpdateTopicGroupResponse} + * + * @example Subscribe a topic group to two folders + * ```typescript + * await subscriptions.updateTopicGroup([ + * { + * publisherId: '', + * topicGroupName: 'JobNotifications', + * entities: [ + * { id: '', type: 'Folder', isSubscribed: true }, + * { id: '', type: 'Folder', isSubscribed: true }, + * ], + * }, + * ]); + * ``` + */ + updateTopicGroup(subscriptions: TopicGroupSubscriptionUpdate[]): Promise; + + /** + * Activates or deactivates a notification channel for a publisher. + * + * Use {@link getSupportedChannels} first to check whether the channel is enabled in the tenant. + * + * @param publisherId - Publisher GUID + * @param mode - Channel and target activation state + * @returns Operation result echoing the new mode state + * {@link SubscriptionUpdateModeResponse} + * + * @example Activate email delivery for a publisher + * ```typescript + * import { NotificationMode } from '@uipath/uipath-typescript/notifications'; + * + * await subscriptions.updateMode('', { + * name: NotificationMode.Email, + * isActive: true, + * }); + * ``` + */ + updateMode(publisherId: string, mode: AllowedMode): Promise; + + /** + * Resets the current user's subscriptions for a publisher to the publisher's defaults. + * + * @param publisherId - Publisher GUID + * @returns The publisher's full subscription state after reset + * {@link SubscriptionGetResponse} + * + * @example + * ```typescript + * const { publishers } = await subscriptions.reset(''); + * ``` + */ + reset(publisherId: string): Promise; +} diff --git a/src/models/notification/subscriptions.types.ts b/src/models/notification/subscriptions.types.ts new file mode 100644 index 000000000..be021d5fc --- /dev/null +++ b/src/models/notification/subscriptions.types.ts @@ -0,0 +1,220 @@ +/** + * Subscription service types — request/response shapes for user subscription preferences. + */ + +import { NotificationCategory, NotificationMode } from './notifications.types'; + +/** + * Status of a notification mode (channel) for a publisher — whether the user has + * activated this channel. + */ +export interface AllowedMode { + /** Notification channel. */ + name: NotificationMode; + /** Whether the user has activated this channel for the publisher. */ + isActive: boolean; +} + +/** + * Per-mode subscription state for a topic. + */ +export interface SubscriptionMode { + /** Notification channel. */ + name: NotificationMode; + /** Whether the user is subscribed to this topic via this channel. */ + isSubscribed: boolean; + /** Whether the topic is subscribed by default via this channel. */ + isSubscribedByDefault: boolean; +} + +/** + * A topic that the user can subscribe to. + * + * Note: the discovery endpoint (`getPublishers()`) returns a subset of these fields — + * only `id`, `name`, `displayName`, `description`, `group`. Subscription-state fields + * (`isSubscribed`, `modes`, etc.) appear only on `getAll()`. + */ +export interface SubscriptionTopic { + /** Topic GUID. */ + id: string; + /** Stable topic identifier (e.g. `Process.JobExecution.Faulted`). */ + name: string; + /** Human-readable topic name. */ + displayName: string | null; + /** Topic description. */ + description: string | null; + /** Severity category. Only populated on `getAll()`. */ + category?: NotificationCategory; + /** Topic group name. */ + group: string | null; + /** Parent topic group name. Often `null`. */ + parentGroup?: string | null; + /** Whether the user is currently subscribed to this topic. Only populated on `getAll()`. */ + isSubscribed?: boolean; + /** Whether the topic is mandatory — cannot be unsubscribed. Only populated on `getAll()`. */ + isMandatory?: boolean; + /** Whether the topic should be visible in the user-facing subscription UI. */ + isVisible?: boolean; + /** Whether the topic is subscribed by default for new users. */ + isDefault?: boolean; + /** Whether notifications for this topic can be batched. */ + isAllowedToBeDispatchedInBatch?: boolean; + /** Whether the topic is marked as infrequent (lower-priority delivery). */ + isInfrequent?: boolean; + /** Number of days notifications for this topic are retained. */ + retentionDays?: number; + /** Display ordering hint. */ + orderingSequence?: number; + /** Per-channel subscription state. Only populated on `getAll()`. */ + modes?: SubscriptionMode[]; +} + +/** + * An entity reference used in publisher / topic-group subscription requests. + */ +export interface SubscriptionEntity { + /** Entity GUID. */ + id?: string; + /** Entity name. */ + name?: string; + /** Entity type. */ + type?: string; + /** Parent name (e.g. folder name for a sub-folder). */ + parentName?: string; + /** Whether the user is subscribed to notifications for this entity. */ + isSubscribed?: boolean; +} + +/** + * Group of entities that belong to a topic group. + */ +export interface TopicGroupEntity { + /** Topic group name. */ + name?: string; + /** Entities belonging to this group. */ + entities?: SubscriptionEntity[]; +} + +/** + * Entity types supported by a topic group. + */ +export interface TopicGroupEntityType { + /** Topic group name. */ + name?: string; + /** Entity type names. */ + entityTypes?: string[]; +} + +/** + * A publisher with its topics, channels, and (when retrieved via `getAll()`) subscription state. + * + * The discovery endpoint (`getPublishers()`) populates only `id`, `name`, `displayName`, + * and `topics` — subscription-state fields (`isUserOptin`, `modes`, `entities`, etc.) + * appear only on `getAll()`. + */ +export interface SubscriptionPublisher { + /** Publisher GUID. */ + id: string; + /** Stable publisher name (e.g. `Orchestrator`, `Actions`). */ + name: string; + /** Human-readable publisher name. */ + displayName: string | null; + /** URL to navigate to when a publisher notification is clicked. */ + redirectionUrl?: string | null; + /** Number of days notifications from this publisher are retained. */ + retentionDays?: number; + /** Whether notifications from this publisher are included in summary digests. */ + addToSummary?: boolean; + /** Whether the user has opted in to receive notifications from this publisher. */ + isUserOptin?: boolean; + /** Channels available for this publisher and their activation state. */ + modes?: AllowedMode[]; + /** Topics published under this publisher. */ + topics: SubscriptionTopic[]; + /** Entities (e.g. folders) that the publisher exposes. */ + entities?: SubscriptionEntity[]; + /** Entity types the publisher exposes. */ + entityTypes?: string[]; + /** Topic-group entity sets. */ + topicGroupEntities?: TopicGroupEntity[]; + /** Topic-group entity types. */ + topicGroupEntityTypes?: TopicGroupEntityType[]; +} + +/** + * Channel availability returned by `getSupportedChannels()`. + * + * Note: `InApp` is never returned — it is always implicitly available. + */ +export interface SupportedChannel { + /** Notification channel. */ + name: NotificationMode; + /** Whether the channel is enabled for the current tenant. */ + isEnabled: boolean; +} + +/** + * Update payload for a topic-level subscription change. + */ +export interface TopicSubscriptionUpdate { + /** Topic GUID. */ + topicId: string; + /** Whether the user should be subscribed to this topic via the chosen mode. */ + isSubscribed: boolean; + /** Notification channel this update applies to. */ + notificationMode: NotificationMode; +} + +/** + * Update payload for a category-level subscription change. + */ +export interface CategorySubscriptionUpdate { + /** Publisher GUID. */ + publisherId: string; + /** Category to update. */ + category: NotificationCategory; + /** Whether the user should be subscribed to topics of this category via the chosen mode. */ + isSubscribed: boolean; + /** Notification channel this update applies to. */ + notificationMode: NotificationMode; +} + +/** + * Update payload for a publisher-level opt-in / opt-out. + */ +export interface PublisherSubscriptionUpdate { + /** Publisher GUID. */ + publisherId: string; + /** Whether the user opts in to receive notifications from this publisher. */ + isUserOptIn: boolean; + /** Optional entity scoping. */ + entities?: SubscriptionEntity[]; +} + +/** + * Update payload for a topic-group-level subscription change. + */ +export interface TopicGroupSubscriptionUpdate { + /** Publisher GUID. */ + publisherId: string; + /** Topic group name. */ + topicGroupName: string; + /** Optional entity scoping. */ + entities?: SubscriptionEntity[]; +} + +/** + * Options for `Subscriptions.getAll()`. + */ +export interface SubscriptionGetAllOptions { + /** Filter to specific publisher names. When omitted, all publishers are returned. */ + publishers?: string[]; +} + +/** + * Options for `Subscriptions.getPublishers()`. + */ +export interface SubscriptionGetPublishersOptions { + /** Filter to a specific publisher name. When omitted, all publishers are returned. */ + name?: string; +} diff --git a/src/services/notification/index.ts b/src/services/notification/index.ts new file mode 100644 index 000000000..2653cf914 --- /dev/null +++ b/src/services/notification/index.ts @@ -0,0 +1,33 @@ +/** + * Notification Service Module + * + * Provides access to the UiPath Notification platform from the perspective of an + * authenticated user (UserContext): + * - `Notifications` — list / mark read / delete operations on the user's inbox + * - `Subscriptions` — manage the user's notification preferences per publisher, topic, and channel + * + * Publishing (sending) notifications is a first-party service action and is NOT part of this module. + * + * @example + * ```typescript + * import { UiPath } from '@uipath/uipath-typescript/core'; + * import { Notifications, Subscriptions } from '@uipath/uipath-typescript/notifications'; + * + * const sdk = new UiPath(config); + * await sdk.initialize(); + * + * const notifications = new Notifications(sdk); + * const unread = await notifications.getAll({ filter: 'isRead eq false' }); + * + * const subscriptions = new Subscriptions(sdk); + * const { publishers } = await subscriptions.getAll(); + * ``` + * + * @module + */ + +export { NotificationService as Notifications } from './notifications'; +export { SubscriptionService as Subscriptions } from './subscriptions'; + +// Models (types, enums, response shapes) +export * from '../../models/notification'; diff --git a/src/services/notification/notifications.ts b/src/services/notification/notifications.ts new file mode 100644 index 000000000..8d4fc1394 --- /dev/null +++ b/src/services/notification/notifications.ts @@ -0,0 +1,231 @@ +/** + * NotificationService — manages the current user's notification inbox. + */ + +import { track } from '../../core/telemetry'; +import { BaseService } from '../base'; + +import type { + NotificationGetAllOptions, + NotificationGetResponse, +} from '../../models/notification/notifications.types'; +import type { + NotificationDeleteAllResponse, + NotificationDeleteResponse, + NotificationMarkAllReadResponse, + NotificationServiceModel, + NotificationUpdateReadResponse, +} from '../../models/notification/notifications.models'; +import { + INTERNAL_NOTIFICATION_FIELDS, + type RawNotificationEntry, +} from '../../models/notification/notifications.internal-types'; + +import { ODATA_OFFSET_PARAMS, ODATA_PAGINATION } from '../../utils/constants/common'; +import { NOTIFICATION_ENDPOINTS } from '../../utils/constants/endpoints'; +import { + HasPaginationOptions, + NonPaginatedResponse, + PaginatedResponse, +} from '../../utils/pagination/types'; +import { PaginationHelpers } from '../../utils/pagination/helpers'; +import { PaginationType } from '../../utils/pagination/internal-types'; + +/** + * Service for interacting with the UiPath Notification inbox. + * + * Provides list / mark-read / delete operations against the current user's + * notifications (the `/odata/v1/NotificationEntry` API). + */ +export class NotificationService extends BaseService implements NotificationServiceModel { + /** + * Lists notifications from the current user's inbox. + * + * Returns the full list when no pagination params are provided, or a paginated cursor result + * when any of `pageSize`/`cursor`/`jumpToPage` is supplied. Supports OData `filter` and + * `orderby` query options. + * + * @param options - Optional OData query and pagination options + * @returns Array of notifications, or a paginated result when pagination params are supplied + * {@link NotificationGetResponse} + * + * @example Basic usage + * ```typescript + * import { Notifications } from '@uipath/uipath-typescript/notifications'; + * + * const notifications = new Notifications(sdk); + * const all = await notifications.getAll(); + * ``` + * + * @example Filter unread, most recent first + * ```typescript + * const unread = await notifications.getAll({ + * filter: 'isRead eq false', + * orderby: 'publishedOn desc', + * }); + * ``` + * + * @example First page with pagination + * ```typescript + * const page1 = await notifications.getAll({ pageSize: 20 }); + * if (page1.hasNextPage) { + * const page2 = await notifications.getAll({ cursor: page1.nextCursor }); + * } + * ``` + */ + @track('Notifications.GetAll') + async getAll( + options?: T + ): Promise< + T extends HasPaginationOptions + ? PaginatedResponse + : NonPaginatedResponse + > { + return PaginationHelpers.getAll({ + serviceAccess: this.createPaginationServiceAccess(), + getEndpoint: () => NOTIFICATION_ENDPOINTS.GET_ALL, + transformFn: stripInternalNotificationFields, + pagination: { + paginationType: PaginationType.OFFSET, + itemsField: ODATA_PAGINATION.ITEMS_FIELD, + totalCountField: ODATA_PAGINATION.TOTAL_COUNT_FIELD, + paginationParams: { + pageSizeParam: ODATA_OFFSET_PARAMS.PAGE_SIZE_PARAM, + offsetParam: ODATA_OFFSET_PARAMS.OFFSET_PARAM, + countParam: ODATA_OFFSET_PARAMS.COUNT_PARAM, + }, + }, + }, options) as Promise< + T extends HasPaginationOptions + ? PaginatedResponse + : NonPaginatedResponse + >; + } + + /** + * Marks the given notifications as read. + * + * @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.markRead(['', '']); + * ``` + */ + @track('Notifications.MarkRead') + async markRead(notificationIds: string[]): Promise { + return this.updateRead(notificationIds, true); + } + + /** + * Marks the given notifications as unread. + * + * @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.markUnread(['']); + * ``` + */ + @track('Notifications.MarkUnread') + async markUnread(notificationIds: string[]): Promise { + return this.updateRead(notificationIds, false); + } + + /** + * Marks all notifications in the current user's inbox as read. + * + * Uses the server-side `forceAllRead` flag — no per-notification IDs are sent. + * + * @returns Operation result confirming the bulk update + * {@link NotificationMarkAllReadResponse} + * + * @example + * ```typescript + * await notifications.markAllRead(); + * ``` + */ + @track('Notifications.MarkAllRead') + async markAllRead(): Promise { + await this.post(NOTIFICATION_ENDPOINTS.UPDATE_READ, { + notifications: [], + forceAllRead: true, + }); + return { success: true, data: { all: true, read: true } }; + } + + /** + * Deletes the given notifications. + * + * @param notificationIds - GUIDs of notifications to delete. Must be non-empty — + * the server rejects an empty array with HTTP 400. + * @returns Operation result echoing the deleted IDs + * {@link NotificationDeleteResponse} + * + * @example + * ```typescript + * await notifications.deleteNotifications(['', '']); + * ``` + */ + @track('Notifications.DeleteNotifications') + async deleteNotifications(notificationIds: string[]): Promise { + await this.post(NOTIFICATION_ENDPOINTS.DELETE_BULK, { + // API spec misspells the key as `notifcationIds` — preserve it. + notifcationIds: notificationIds, + deleteAll: false, + }); + return { success: true, data: { notificationIds } }; + } + + /** + * Deletes all notifications from the current user's inbox. + * + * Uses the server-side `deleteAll` flag — no per-notification IDs are sent. + * + * @returns Operation result confirming the bulk delete + * {@link NotificationDeleteAllResponse} + * + * @example + * ```typescript + * await notifications.deleteAll(); + * ``` + */ + @track('Notifications.DeleteAll') + async deleteAll(): Promise { + await this.post(NOTIFICATION_ENDPOINTS.DELETE_BULK, { + // API spec misspells the key as `notifcationIds` — preserve it. + notifcationIds: [], + deleteAll: true, + }); + return { success: true, data: { all: true } }; + } + + private async updateRead( + notificationIds: string[], + read: boolean + ): Promise { + await this.post(NOTIFICATION_ENDPOINTS.UPDATE_READ, { + notifications: notificationIds.map((notificationId) => ({ notificationId, read })), + forceAllRead: false, + }); + return { success: true, data: { notificationIds, read } }; + } +} + +/** + * Drops internal/transport-layer fields from a raw notification entry before + * returning it to the SDK consumer. Exported as module-level for testability. + * + * @internal + */ +export function stripInternalNotificationFields(item: RawNotificationEntry): NotificationGetResponse { + const result: RawNotificationEntry = { ...item }; + for (const field of INTERNAL_NOTIFICATION_FIELDS) { + delete result[field]; + } + return result; +} diff --git a/src/services/notification/subscriptions.ts b/src/services/notification/subscriptions.ts new file mode 100644 index 000000000..efc30c140 --- /dev/null +++ b/src/services/notification/subscriptions.ts @@ -0,0 +1,295 @@ +/** + * SubscriptionService — manages the current user's notification preferences. + */ + +import { track } from '../../core/telemetry'; +import { BaseService } from '../base'; + +import type { + AllowedMode, + CategorySubscriptionUpdate, + PublisherSubscriptionUpdate, + SubscriptionGetAllOptions, + SubscriptionGetPublishersOptions, + TopicGroupSubscriptionUpdate, + TopicSubscriptionUpdate, +} from '../../models/notification/subscriptions.types'; +import type { + SubscriptionGetResponse, + SubscriptionGetSupportedChannelsResponse, + SubscriptionServiceModel, + SubscriptionUpdateCategoryResponse, + SubscriptionUpdateModeResponse, + SubscriptionUpdatePublisherResponse, + SubscriptionUpdateTopicGroupResponse, + SubscriptionUpdateTopicResponse, +} from '../../models/notification/subscriptions.models'; + +import { SUBSCRIPTION_ENDPOINTS } from '../../utils/constants/endpoints'; + +/** + * Service for managing the current user's notification subscription preferences. + * + * Subscriptions are scoped to publishers (e.g. `Orchestrator`, `Actions`) and topics + * within them. Each topic can be activated per notification channel (InApp, Email, + * Slack, Teams) — see {@link NotificationMode}. + */ +export class SubscriptionService extends BaseService implements SubscriptionServiceModel { + /** + * Gets the current user's subscription preferences, optionally filtered to a set of + * publisher names. + * + * Returns the full list of publishers (their topics, channels, and current subscription + * state) the user has access to. Use {@link SubscriptionGetAllOptions.publishers} to + * narrow to specific publishers. + * + * @param options - Optional publisher-name filter + * @returns Full subscription state for the matched publishers + * {@link SubscriptionGetResponse} + * + * @example Basic usage + * ```typescript + * import { Subscriptions } from '@uipath/uipath-typescript/notifications'; + * + * const subscriptions = new Subscriptions(sdk); + * const { publishers } = await subscriptions.getAll(); + * ``` + * + * @example Filter to specific publishers + * ```typescript + * const { publishers } = await subscriptions.getAll({ + * publishers: ['Orchestrator', 'Actions'], + * }); + * ``` + */ + @track('Subscriptions.GetAll') + async getAll(options?: SubscriptionGetAllOptions): Promise { + const response = await this.get( + SUBSCRIPTION_ENDPOINTS.GET_ALL, + options?.publishers ? { params: { Publishers: options.publishers } } : undefined + ); + return response.data; + } + + /** + * Lists available publishers and their topics, regardless of the user's current subscription. + * + * Used for discovery — pair with {@link getAll} to inspect what's subscribable, then + * call {@link updateTopic}, {@link updatePublisher}, or {@link updateMode} to change preferences. + * + * Note: the response from this endpoint carries only identity/discovery fields on + * publishers and topics (no subscription state). Use {@link getAll} to inspect state. + * + * @param options - Optional publisher-name filter + * @returns Publishers and their full topic catalogue + * {@link SubscriptionGetResponse} + * + * @example Basic usage + * ```typescript + * const { publishers } = await subscriptions.getPublishers(); + * ``` + * + * @example Filter to a single publisher + * ```typescript + * const { publishers } = await subscriptions.getPublishers({ name: 'Orchestrator' }); + * ``` + */ + @track('Subscriptions.GetPublishers') + async getPublishers(options?: SubscriptionGetPublishersOptions): Promise { + const response = await this.get( + SUBSCRIPTION_ENDPOINTS.GET_PUBLISHERS, + options?.name ? { params: { PublisherName: options.name } } : undefined + ); + return response.data; + } + + /** + * Gets the notification channels supported in the current tenant. + * + * Check {@link SupportedChannel.isEnabled} before attempting to subscribe to a channel + * via {@link updateMode} — disabled channels will be rejected by the server. + * + * Note: `InApp` is always available and is not included in the response. + * + * @returns Supported channels with enabled status + * {@link SubscriptionGetSupportedChannelsResponse} + * + * @example + * ```typescript + * const { channels } = await subscriptions.getSupportedChannels(); + * const slack = channels.find(c => c.name === 'Slack'); + * if (slack?.isEnabled) { + * // safe to subscribe to Slack + * } + * ``` + */ + @track('Subscriptions.GetSupportedChannels') + async getSupportedChannels(): Promise { + const response = await this.get( + SUBSCRIPTION_ENDPOINTS.GET_SUPPORTED_CHANNELS + ); + return response.data; + } + + /** + * Updates topic-level subscription preferences. Each entry sets the subscription state + * (`isSubscribed`) for a single (topic, mode) pair. + * + * @param subscriptions - Topic subscription updates + * @returns Operation result echoing the submitted updates + * {@link SubscriptionUpdateTopicResponse} + * + * @example Unsubscribe a topic from a single channel + * ```typescript + * import { NotificationMode } from '@uipath/uipath-typescript/notifications'; + * + * await subscriptions.updateTopic([ + * { topicId: '', isSubscribed: false, notificationMode: NotificationMode.Email }, + * ]); + * ``` + */ + @track('Subscriptions.UpdateTopic') + async updateTopic(subscriptions: TopicSubscriptionUpdate[]): Promise { + await this.post(SUBSCRIPTION_ENDPOINTS.UPDATE_TOPIC, { + userSubscriptions: subscriptions, + }); + return { success: true, data: { subscriptions } }; + } + + /** + * Updates category-level subscription preferences for a publisher. Each entry sets + * the subscription state for all topics of a given category via a given mode. + * + * @param subscriptions - Category subscription updates + * @returns Operation result echoing the submitted updates + * {@link SubscriptionUpdateCategoryResponse} + * + * @example Unsubscribe from all `Error` topics via email for one publisher + * ```typescript + * import { NotificationCategory, NotificationMode } from '@uipath/uipath-typescript/notifications'; + * + * await subscriptions.updateCategory([ + * { + * publisherId: '', + * category: NotificationCategory.Error, + * isSubscribed: false, + * notificationMode: NotificationMode.Email, + * }, + * ]); + * ``` + */ + @track('Subscriptions.UpdateCategory') + async updateCategory(subscriptions: CategorySubscriptionUpdate[]): Promise { + await this.post(SUBSCRIPTION_ENDPOINTS.UPDATE_CATEGORY, { + categorySubscriptions: subscriptions, + }); + return { success: true, data: { subscriptions } }; + } + + /** + * Updates publisher-level opt-in / opt-out. Each entry toggles the user's overall + * opt-in for a publisher and optionally scopes the change to specific entities. + * + * @param subscriptions - Publisher subscription updates + * @returns Operation result echoing the submitted updates + * {@link SubscriptionUpdatePublisherResponse} + * + * @example Opt out of a publisher entirely + * ```typescript + * await subscriptions.updatePublisher([ + * { publisherId: '', isUserOptIn: false }, + * ]); + * ``` + */ + @track('Subscriptions.UpdatePublisher') + async updatePublisher(subscriptions: PublisherSubscriptionUpdate[]): Promise { + // API field is misspelled `publisherID` — map at send time. + await this.post(SUBSCRIPTION_ENDPOINTS.UPDATE_PUBLISHER, { + publisherSubscriptions: subscriptions.map(({ publisherId, isUserOptIn, entities }) => ({ + publisherID: publisherId, + isUserOptIn, + entities, + })), + }); + return { success: true, data: { subscriptions } }; + } + + /** + * Updates topic-group subscription preferences. Each entry scopes a topic group to + * a specific set of entities. + * + * @param subscriptions - Topic-group subscription updates + * @returns Operation result echoing the submitted updates + * {@link SubscriptionUpdateTopicGroupResponse} + * + * @example Subscribe a topic group to two folders + * ```typescript + * await subscriptions.updateTopicGroup([ + * { + * publisherId: '', + * topicGroupName: 'JobNotifications', + * entities: [ + * { id: '', type: 'Folder', isSubscribed: true }, + * { id: '', type: 'Folder', isSubscribed: true }, + * ], + * }, + * ]); + * ``` + */ + @track('Subscriptions.UpdateTopicGroup') + async updateTopicGroup(subscriptions: TopicGroupSubscriptionUpdate[]): Promise { + await this.post(SUBSCRIPTION_ENDPOINTS.UPDATE_TOPIC_GROUP, { + topicGroupSubscriptions: subscriptions, + }); + return { success: true, data: { subscriptions } }; + } + + /** + * Activates or deactivates a notification channel for a publisher. + * + * Use {@link getSupportedChannels} first to check whether the channel is enabled in the tenant. + * + * @param publisherId - Publisher GUID + * @param mode - Channel and target activation state + * @returns Operation result echoing the new mode state + * {@link SubscriptionUpdateModeResponse} + * + * @example Activate email delivery for a publisher + * ```typescript + * import { NotificationMode } from '@uipath/uipath-typescript/notifications'; + * + * await subscriptions.updateMode('', { + * name: NotificationMode.Email, + * isActive: true, + * }); + * ``` + */ + @track('Subscriptions.UpdateMode') + async updateMode(publisherId: string, mode: AllowedMode): Promise { + await this.post(SUBSCRIPTION_ENDPOINTS.UPDATE_MODE, { + publisherId, + publisherMode: mode, + }); + return { success: true, data: { publisherId, mode } }; + } + + /** + * Resets the current user's subscriptions for a publisher to the publisher's defaults. + * + * @param publisherId - Publisher GUID + * @returns The publisher's full subscription state after reset + * {@link SubscriptionGetResponse} + * + * @example + * ```typescript + * const { publishers } = await subscriptions.reset(''); + * ``` + */ + @track('Subscriptions.Reset') + async reset(publisherId: string): Promise { + const response = await this.post(SUBSCRIPTION_ENDPOINTS.RESET, { + publisherId, + }); + return response.data; + } +} diff --git a/src/utils/constants/endpoints/base.ts b/src/utils/constants/endpoints/base.ts index 6bfa8f393..cef473f74 100644 --- a/src/utils/constants/endpoints/base.ts +++ b/src/utils/constants/endpoints/base.ts @@ -9,3 +9,14 @@ export const IDENTITY_BASE = 'identity_'; export const AUTOPILOT_BASE = 'autopilotforeveryone_'; export const LLMOPS_BASE = 'llmopstenant_'; export const INSIGHTS_RTM_BASE = 'insightsrtm_'; +/** + * Notification service base. The notification service is routed at the **organization** + * level — its URLs do not include a tenant segment (unlike most UiPath services). + * + * The `../` prefix relies on `URL` path normalization to collapse the tenant segment + * that {@link ApiClient} unconditionally inserts (`{orgName}/{tenantName}/{path}`). Concretely, + * `{orgName}/{tenantName}/../notificationservice_/...` resolves to `{orgName}/notificationservice_/...`. + * + * Do NOT remove the leading `../`. See real-API curl confirmation in the PR description. + */ +export const NOTIFICATION_BASE = '../notificationservice_'; diff --git a/src/utils/constants/endpoints/index.ts b/src/utils/constants/endpoints/index.ts index ecd1d4064..ecb95a58d 100644 --- a/src/utils/constants/endpoints/index.ts +++ b/src/utils/constants/endpoints/index.ts @@ -28,3 +28,6 @@ export * from './feedback'; export * from './traces'; // Governance endpoints export * from './governance'; + +// Notification endpoints +export * from './notification'; diff --git a/src/utils/constants/endpoints/notification.ts b/src/utils/constants/endpoints/notification.ts new file mode 100644 index 000000000..1b9b45c06 --- /dev/null +++ b/src/utils/constants/endpoints/notification.ts @@ -0,0 +1,39 @@ +/** + * Notification Service Endpoints + * + * Covers two backend sub-services under the same `notificationservice_` prefix: + * - `notificationserviceapi` — notification inbox (OData) + * - `usersubscriptionservice` — user subscription preferences + * + * URLs route at the **organization** level (no tenant segment); see {@link NOTIFICATION_BASE}. + */ + +import { NOTIFICATION_BASE } from './base'; + +const NOTIFICATION_API_BASE = `${NOTIFICATION_BASE}/notificationserviceapi`; +const SUBSCRIPTION_API_BASE = `${NOTIFICATION_BASE}/usersubscriptionservice`; + +/** + * Notification inbox endpoints + */ +export const NOTIFICATION_ENDPOINTS = { + GET_ALL: `${NOTIFICATION_API_BASE}/odata/v1/NotificationEntry`, + UPDATE_READ: `${NOTIFICATION_API_BASE}/odata/v1/NotificationEntry/UiPath.NotificationService.Api.UpdateRead`, + DELETE_BULK: `${NOTIFICATION_API_BASE}/odata/v1/NotificationEntry/UiPath.NotificationService.Api.DeleteBulk`, +} as const; + +/** + * User subscription endpoints + */ +export const SUBSCRIPTION_ENDPOINTS = { + GET_ALL: `${SUBSCRIPTION_API_BASE}/api/v1/UserSubscription`, + GET_PUBLISHERS: `${SUBSCRIPTION_API_BASE}/api/v1/UserSubscription/GetPublishers`, + // Intentional duplicate of GET_ALL: same URL, POST vs GET differentiates the operation. + UPDATE_TOPIC: `${SUBSCRIPTION_API_BASE}/api/v1/UserSubscription`, + UPDATE_TOPIC: `${SUBSCRIPTION_API_BASE}/api/v1/UserSubscription`, + UPDATE_CATEGORY: `${SUBSCRIPTION_API_BASE}/api/v1/UserSubscription/CategorySubscription`, + UPDATE_PUBLISHER: `${SUBSCRIPTION_API_BASE}/api/v1/UserSubscription/PublisherSubscription`, + UPDATE_TOPIC_GROUP: `${SUBSCRIPTION_API_BASE}/api/v1/UserSubscription/TopicGroupSubscription`, + UPDATE_MODE: `${SUBSCRIPTION_API_BASE}/api/v1/UserSubscription/UpdateMode`, + RESET: `${SUBSCRIPTION_API_BASE}/api/v1/UserSubscription/Reset`, +} as const; diff --git a/tests/integration/config/unified-setup.ts b/tests/integration/config/unified-setup.ts index f02b1d093..2631007e7 100644 --- a/tests/integration/config/unified-setup.ts +++ b/tests/integration/config/unified-setup.ts @@ -13,6 +13,7 @@ import { import { Feedback } from '../../../src/services/agents/feedback'; import { Traces } from '../../../src/services/observability/traces'; import { Governance } from '../../../src/services/governance'; +import { Notifications, Subscriptions } from '../../../src/services/notification'; import { loadIntegrationConfig, IntegrationConfig } from './test-config'; import { UiPath as LegacyUiPath } from '../../../src/uipath'; import { afterAll, beforeAll } from 'vitest'; @@ -50,6 +51,8 @@ export interface TestServices { feedback?: Feedback; traces?: Traces; governance?: Governance; + notifications?: Notifications; + subscriptions?: Subscriptions; } /** @@ -132,6 +135,8 @@ function createV1Services(config: IntegrationConfig): TestServices { feedback: new Feedback(sdk), traces: new Traces(sdk), governance: new Governance(sdk), + notifications: new Notifications(sdk), + subscriptions: new Subscriptions(sdk), }; } diff --git a/tests/integration/shared/notification/notifications.integration.test.ts b/tests/integration/shared/notification/notifications.integration.test.ts new file mode 100644 index 000000000..4a0f07305 --- /dev/null +++ b/tests/integration/shared/notification/notifications.integration.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { getServices, setupUnifiedTests, InitMode } from '../../config/unified-setup'; +import type { Notifications } from '../../../../src/services/notification'; +import { INTERNAL_NOTIFICATION_FIELDS } from '../../../../src/models/notification/notifications.internal-types'; + +const modes: InitMode[] = ['v1']; + +describe.each(modes)('Notifications - Integration Tests [%s]', (mode) => { + setupUnifiedTests(mode); + + let notifications!: Notifications; + + beforeAll(() => { + const service = getServices().notifications; + if (!service) { + throw new Error('Notifications service is not registered for this init mode'); + } + notifications = service; + }); + + describe('getAll', () => { + it('should retrieve notifications without pagination options as a NonPaginatedResponse', async () => { + const result = await notifications.getAll(); + + expect(result).toBeDefined(); + expect(Array.isArray(result.items)).toBe(true); + }); + + it('should retrieve notifications with pagination options as a PaginatedResponse', async () => { + const result = await notifications.getAll({ pageSize: 5 }); + + expect(result).toBeDefined(); + expect(Array.isArray(result.items)).toBe(true); + expect(result.items.length).toBeLessThanOrEqual(5); + expect(result.currentPage).toBe(1); + expect(result.supportsPageJump).toBe(true); + expect(typeof result.hasNextPage).toBe('boolean'); + }); + + it('should support OData filter and orderby', async () => { + const result = await notifications.getAll({ + filter: 'isRead eq false', + orderby: 'publishedOn desc', + pageSize: 3, + }); + + expect(result).toBeDefined(); + expect(Array.isArray(result.items)).toBe(true); + for (const item of result.items) { + expect(item.isRead).toBe(false); + } + }); + + it('should drop internal API fields (entityOrgName, partitionKey, etc.) from each item', async () => { + const result = await notifications.getAll({ pageSize: 1 }); + + if (result.items.length === 0) { + throw new Error( + 'Inbox is empty — cannot validate transform. Trigger at least one notification on the test tenant.' + ); + } + + const item = result.items[0]; + // Public fields present + expect(item.id).toBeDefined(); + expect(typeof item.isRead).toBe('boolean'); + expect(item.publisherName).toBeDefined(); + expect(item.topicName).toBeDefined(); + expect(typeof item.publishedOn).toBe('number'); + + // Internal fields stripped — assert against the same source-of-truth list the service uses + for (const field of INTERNAL_NOTIFICATION_FIELDS) { + expect((item as Record)[field]).toBeUndefined(); + } + }); + }); + + describe('mark-read flows', () => { + it('should mark a single notification as read and reflect the change via getAll', async () => { + const unread = await notifications.getAll({ filter: 'isRead eq false', pageSize: 1 }); + if (unread.items.length === 0) { + throw new Error( + 'No unread notifications in the inbox — cannot validate markRead. Trigger one on the test tenant.' + ); + } + const target = unread.items[0]; + + const mark = await notifications.markRead([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.markUnread([target.id]); + expect(restore.success).toBe(true); + expect(restore.data.read).toBe(false); + }); + + it('markAllRead should succeed without per-id payload', async () => { + const result = await notifications.markAllRead(); + expect(result.success).toBe(true); + expect(result.data).toEqual({ all: true, read: true }); + }); + }); + + // Note: no deleteNotifications / deleteAll integration tests — these destructively + // mutate the inbox with no SDK-level undo. Run manually if needed. +}); diff --git a/tests/integration/shared/notification/subscriptions.integration.test.ts b/tests/integration/shared/notification/subscriptions.integration.test.ts new file mode 100644 index 000000000..a766d388f --- /dev/null +++ b/tests/integration/shared/notification/subscriptions.integration.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { getServices, setupUnifiedTests, InitMode } from '../../config/unified-setup'; +import type { Subscriptions } from '../../../../src/services/notification'; +import { NotificationMode, type SubscriptionPublisher } from '../../../../src/models/notification'; + +const modes: InitMode[] = ['v1']; + +describe.each(modes)('Subscriptions - Integration Tests [%s]', (mode) => { + setupUnifiedTests(mode); + + let subscriptions!: Subscriptions; + let firstPublisher!: SubscriptionPublisher; + + beforeAll(async () => { + const service = getServices().subscriptions; + if (!service) { + throw new Error('Subscriptions service is not registered for this init mode'); + } + subscriptions = service; + + const { publishers } = await subscriptions.getAll(); + if (publishers.length === 0) { + throw new Error('No publishers visible to the test user — cannot run subscription tests.'); + } + firstPublisher = publishers[0]; + }); + + describe('getAll', () => { + it('should return the user\'s publishers + topics + subscription state', () => { + expect(firstPublisher).toBeDefined(); + expect(firstPublisher.id).toBeDefined(); + expect(firstPublisher.name).toBeDefined(); + expect(Array.isArray(firstPublisher.topics)).toBe(true); + expect(Array.isArray(firstPublisher.modes)).toBe(true); + expect(typeof firstPublisher.isUserOptin).toBe('boolean'); + }); + + it('should support filtering by publisher names', async () => { + const { publishers } = await subscriptions.getAll({ publishers: [firstPublisher.name] }); + + expect(publishers.length).toBeGreaterThanOrEqual(1); + for (const p of publishers) { + expect(p.name).toBe(firstPublisher.name); + } + }); + }); + + describe('getPublishers', () => { + it('should list available publishers with discovery-only fields', async () => { + const { publishers } = await subscriptions.getPublishers(); + + expect(Array.isArray(publishers)).toBe(true); + expect(publishers.length).toBeGreaterThan(0); + const p = publishers[0]; + expect(p.id).toBeDefined(); + expect(p.name).toBeDefined(); + expect(Array.isArray(p.topics)).toBe(true); + }); + + it('should support filtering by publisher name', async () => { + const { publishers } = await subscriptions.getPublishers({ name: firstPublisher.name }); + + expect(publishers.length).toBeGreaterThanOrEqual(1); + expect(publishers[0].name).toBe(firstPublisher.name); + }); + }); + + describe('getSupportedChannels', () => { + it('should return supported channels for the tenant (excluding implicit InApp)', async () => { + const { channels } = await subscriptions.getSupportedChannels(); + + expect(Array.isArray(channels)).toBe(true); + // InApp is always implicit; the endpoint shouldn't list it + expect(channels.map(c => c.name)).not.toContain(NotificationMode.InApp); + for (const channel of channels) { + expect(channel.name).toBeDefined(); + expect(typeof channel.isEnabled).toBe('boolean'); + } + }); + }); + + describe('updateTopic', () => { + it('should round-trip a topic subscription change', async () => { + // Find a non-mandatory topic with at least one mode we can toggle + const topic = firstPublisher.topics.find((t) => t.isMandatory === false && (t.modes?.length ?? 0) > 0); + if (!topic) { + throw new Error('No non-mandatory topics with toggleable modes — cannot run updateTopic round-trip.'); + } + const mode = topic.modes![0]; + const originalState = mode.isSubscribed; + + // Flip the state + const flip = await subscriptions.updateTopic([ + { topicId: topic.id, isSubscribed: !originalState, notificationMode: mode.name }, + ]); + expect(flip.success).toBe(true); + + // Restore — leave the tenant in its original state + const restore = await subscriptions.updateTopic([ + { topicId: topic.id, isSubscribed: originalState, notificationMode: mode.name }, + ]); + expect(restore.success).toBe(true); + }); + }); + + describe('updatePublisher', () => { + it('should round-trip a publisher opt-in/out', async () => { + const original = firstPublisher.isUserOptin === true; + + const flip = await subscriptions.updatePublisher([ + { publisherId: firstPublisher.id, isUserOptIn: !original }, + ]); + expect(flip.success).toBe(true); + + const restore = await subscriptions.updatePublisher([ + { publisherId: firstPublisher.id, isUserOptIn: original }, + ]); + expect(restore.success).toBe(true); + }); + }); + + describe('reset', () => { + it('should reset publisher subscriptions and return updated state', async () => { + const result = await subscriptions.reset(firstPublisher.id); + + expect(Array.isArray(result.publishers)).toBe(true); + const resetPub = result.publishers.find((p) => p.id === firstPublisher.id); + expect(resetPub).toBeDefined(); + }); + }); + + // updateCategory / updateTopicGroup / updateMode require richer tenant fixtures + // (active Slack/Teams channels, configured topic groups) — skipped in CI; covered + // by unit tests for SDK shape, and by manual smoke tests for live behaviour. +}); diff --git a/tests/unit/services/notification/notifications.test.ts b/tests/unit/services/notification/notifications.test.ts new file mode 100644 index 000000000..27e76e52b --- /dev/null +++ b/tests/unit/services/notification/notifications.test.ts @@ -0,0 +1,257 @@ +// ===== IMPORTS ===== +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { + NotificationService, + stripInternalNotificationFields, +} from '../../../../src/services/notification/notifications'; +import { ApiClient } from '../../../../src/core/http/api-client'; +import { PaginationHelpers } from '../../../../src/utils/pagination/helpers'; +import { + createBasicNotificationEntry, + NOTIFICATION_TEST_CONSTANTS, + TEST_CONSTANTS, + createMockError, +} from '../../../utils/mocks'; +import { createServiceTestDependencies, createMockApiClient } from '../../../utils/setup'; +import { NOTIFICATION_ENDPOINTS } from '../../../../src/utils/constants/endpoints'; +import { NotificationCategory, NotificationPriority } from '../../../../src/models/notification'; +import type { RawNotificationEntry } from '../../../../src/models/notification/notifications.internal-types'; + +// ===== MOCKING ===== +vi.mock('../../../../src/core/http/api-client'); + +const mocks = vi.hoisted(() => import('../../../utils/mocks/core')); + +vi.mock('../../../../src/utils/pagination/helpers', async () => (await mocks).mockPaginationHelpers); + +// ===== TEST SUITE ===== +describe('NotificationService Unit Tests', () => { + let notificationService: NotificationService; + let mockApiClient: ReturnType; + + beforeEach(() => { + const { instance } = createServiceTestDependencies(); + mockApiClient = createMockApiClient(); + vi.mocked(ApiClient).mockImplementation(() => mockApiClient as unknown as ApiClient); + vi.mocked(PaginationHelpers.getAll).mockReset(); + + notificationService = new NotificationService(instance); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('getAll', () => { + it('should return a list of notifications via PaginationHelpers with OData pagination params', async () => { + const items = [createBasicNotificationEntry()]; + vi.mocked(PaginationHelpers.getAll).mockResolvedValue({ items, totalCount: 1 }); + + const result = await notificationService.getAll(); + + expect(result.items.length).toBe(1); + expect(result.totalCount).toBe(1); + + expect(PaginationHelpers.getAll).toHaveBeenCalledWith( + expect.objectContaining({ + serviceAccess: expect.any(Object), + getEndpoint: expect.any(Function), + transformFn: stripInternalNotificationFields, + pagination: expect.objectContaining({ + itemsField: 'value', + totalCountField: '@odata.count', + paginationParams: expect.objectContaining({ + pageSizeParam: '$top', + offsetParam: '$skip', + countParam: '$count', + }), + }), + }), + undefined + ); + }); + + it('should pass query/pagination options through to PaginationHelpers', async () => { + vi.mocked(PaginationHelpers.getAll).mockResolvedValue({ items: [], totalCount: 0 }); + + await notificationService.getAll({ + filter: 'isRead eq false', + orderby: 'publishedOn desc', + pageSize: 20, + }); + + expect(PaginationHelpers.getAll).toHaveBeenCalledWith( + expect.anything(), + { filter: 'isRead eq false', orderby: 'publishedOn desc', pageSize: 20 } + ); + }); + + it('should propagate errors from PaginationHelpers', async () => { + const error = createMockError(TEST_CONSTANTS.ERROR_MESSAGE); + vi.mocked(PaginationHelpers.getAll).mockRejectedValue(error); + + await expect(notificationService.getAll()).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + }); + + describe('markRead', () => { + it('should POST per-id read=true entries to UpdateRead endpoint', async () => { + mockApiClient.post.mockResolvedValue(undefined); + const ids = [ + NOTIFICATION_TEST_CONSTANTS.NOTIFICATION_ID, + NOTIFICATION_TEST_CONSTANTS.NOTIFICATION_ID_2, + ]; + + const result = await notificationService.markRead(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, + }, + expect.any(Object) + ); + 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.markRead([NOTIFICATION_TEST_CONSTANTS.NOTIFICATION_ID]) + ).rejects.toThrow(NOTIFICATION_TEST_CONSTANTS.ERROR_NOTIFICATION_NOT_FOUND); + }); + }); + + describe('markUnread', () => { + it('should POST per-id read=false entries to UpdateRead endpoint', async () => { + mockApiClient.post.mockResolvedValue(undefined); + const ids = [NOTIFICATION_TEST_CONSTANTS.NOTIFICATION_ID]; + + const result = await notificationService.markUnread(ids); + + expect(mockApiClient.post).toHaveBeenCalledWith( + NOTIFICATION_ENDPOINTS.UPDATE_READ, + { + notifications: [ + { notificationId: NOTIFICATION_TEST_CONSTANTS.NOTIFICATION_ID, read: false }, + ], + forceAllRead: false, + }, + expect.any(Object) + ); + expect(result).toEqual({ success: true, data: { notificationIds: ids, read: false } }); + }); + + it('should propagate errors', async () => { + mockApiClient.post.mockRejectedValue(createMockError(TEST_CONSTANTS.ERROR_MESSAGE)); + + await expect( + notificationService.markUnread([NOTIFICATION_TEST_CONSTANTS.NOTIFICATION_ID]) + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + }); + + describe('markAllRead', () => { + it('should POST forceAllRead=true with empty notifications array', async () => { + mockApiClient.post.mockResolvedValue(undefined); + + const result = await notificationService.markAllRead(); + + expect(mockApiClient.post).toHaveBeenCalledWith( + NOTIFICATION_ENDPOINTS.UPDATE_READ, + { notifications: [], forceAllRead: true }, + expect.any(Object) + ); + expect(result).toEqual({ success: true, data: { all: true, read: true } }); + }); + + it('should propagate errors', async () => { + mockApiClient.post.mockRejectedValue(createMockError(TEST_CONSTANTS.ERROR_MESSAGE)); + + await expect(notificationService.markAllRead()).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + }); + + describe('deleteNotifications', () => { + it('should POST notifcationIds (preserving the API typo) and deleteAll=false', async () => { + mockApiClient.post.mockResolvedValue(undefined); + const ids = [ + NOTIFICATION_TEST_CONSTANTS.NOTIFICATION_ID, + NOTIFICATION_TEST_CONSTANTS.NOTIFICATION_ID_2, + ]; + + const result = await notificationService.deleteNotifications(ids); + + expect(mockApiClient.post).toHaveBeenCalledWith( + NOTIFICATION_ENDPOINTS.DELETE_BULK, + { notifcationIds: ids, deleteAll: false }, + expect.any(Object) + ); + expect(result).toEqual({ success: true, data: { notificationIds: ids } }); + }); + + it('should propagate errors', async () => { + mockApiClient.post.mockRejectedValue(createMockError(TEST_CONSTANTS.ERROR_MESSAGE)); + + await expect( + notificationService.deleteNotifications([NOTIFICATION_TEST_CONSTANTS.NOTIFICATION_ID]) + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + }); + + describe('deleteAll', () => { + it('should POST deleteAll=true with empty notifcationIds array', async () => { + mockApiClient.post.mockResolvedValue(undefined); + + const result = await notificationService.deleteAll(); + + expect(mockApiClient.post).toHaveBeenCalledWith( + NOTIFICATION_ENDPOINTS.DELETE_BULK, + { notifcationIds: [], deleteAll: true }, + expect.any(Object) + ); + expect(result).toEqual({ success: true, data: { all: true } }); + }); + + it('should propagate errors', async () => { + mockApiClient.post.mockRejectedValue(createMockError(TEST_CONSTANTS.ERROR_MESSAGE)); + + await expect(notificationService.deleteAll()).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + }); + + describe('stripInternalNotificationFields', () => { + it('removes all 8 internal fields without mutating the original', () => { + const raw: RawNotificationEntry = createBasicNotificationEntry(); + const before = JSON.stringify(raw); + + const stripped = stripInternalNotificationFields(raw); + + // original untouched (shallow-copy semantics) + expect(JSON.stringify(raw)).toBe(before); + + // every internal field stripped + expect(stripped).not.toHaveProperty('entityOrgName'); + expect(stripped).not.toHaveProperty('entityTenantName'); + expect(stripped).not.toHaveProperty('serviceRegistryName'); + expect(stripped).not.toHaveProperty('messageTemplateKey'); + expect(stripped).not.toHaveProperty('messageVersion'); + expect(stripped).not.toHaveProperty('publicationId'); + expect(stripped).not.toHaveProperty('correlationId'); + expect(stripped).not.toHaveProperty('partitionKey'); + + // public fields preserved with exact values + expect(stripped.id).toBe(NOTIFICATION_TEST_CONSTANTS.NOTIFICATION_ID); + expect(stripped.priority).toBe(NotificationPriority.High); + expect(stripped.category).toBe(NotificationCategory.Error); + expect(stripped.publishedOn).toBe(NOTIFICATION_TEST_CONSTANTS.PUBLISHED_ON); + expect(stripped.message).toBe(NOTIFICATION_TEST_CONSTANTS.MESSAGE); + expect(stripped.isRead).toBe(false); + }); + }); +}); diff --git a/tests/unit/services/notification/subscriptions.test.ts b/tests/unit/services/notification/subscriptions.test.ts new file mode 100644 index 000000000..537ce2f6a --- /dev/null +++ b/tests/unit/services/notification/subscriptions.test.ts @@ -0,0 +1,377 @@ +// ===== IMPORTS ===== +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { SubscriptionService } from '../../../../src/services/notification/subscriptions'; +import { ApiClient } from '../../../../src/core/http/api-client'; +import { + createBasicSubscriptionPublisher, + createBasicSupportedChannels, + NOTIFICATION_TEST_CONSTANTS, + TEST_CONSTANTS, + createMockError, +} from '../../../utils/mocks'; +import { createServiceTestDependencies, createMockApiClient } from '../../../utils/setup'; +import { SUBSCRIPTION_ENDPOINTS } from '../../../../src/utils/constants/endpoints'; +import { + NotificationCategory, + NotificationMode, + type CategorySubscriptionUpdate, + type PublisherSubscriptionUpdate, + type TopicGroupSubscriptionUpdate, + type TopicSubscriptionUpdate, +} from '../../../../src/models/notification'; + +// ===== MOCKING ===== +vi.mock('../../../../src/core/http/api-client'); + +// ===== TEST SUITE ===== +describe('SubscriptionService Unit Tests', () => { + let subscriptionService: SubscriptionService; + let mockApiClient: ReturnType; + + beforeEach(() => { + const { instance } = createServiceTestDependencies(); + mockApiClient = createMockApiClient(); + vi.mocked(ApiClient).mockImplementation(() => mockApiClient as unknown as ApiClient); + + subscriptionService = new SubscriptionService(instance); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('getAll', () => { + it('should GET UserSubscription without params when no publishers filter', async () => { + const mockData = { publishers: [createBasicSubscriptionPublisher()] }; + mockApiClient.get.mockResolvedValue(mockData); + + const result = await subscriptionService.getAll(); + + expect(mockApiClient.get).toHaveBeenCalledWith(SUBSCRIPTION_ENDPOINTS.GET_ALL, {}); + expect(result).toEqual(mockData); + }); + + it('should GET UserSubscription with Publishers param when filter supplied', async () => { + const mockData = { publishers: [createBasicSubscriptionPublisher()] }; + mockApiClient.get.mockResolvedValue(mockData); + + const result = await subscriptionService.getAll({ + publishers: [NOTIFICATION_TEST_CONSTANTS.PUBLISHER_NAME, NOTIFICATION_TEST_CONSTANTS.PUBLISHER_NAME_ALT], + }); + + expect(mockApiClient.get).toHaveBeenCalledWith( + SUBSCRIPTION_ENDPOINTS.GET_ALL, + { + params: { + Publishers: [ + NOTIFICATION_TEST_CONSTANTS.PUBLISHER_NAME, + NOTIFICATION_TEST_CONSTANTS.PUBLISHER_NAME_ALT, + ], + }, + } + ); + expect(result).toEqual(mockData); + }); + + it('should propagate errors', async () => { + mockApiClient.get.mockRejectedValue(createMockError(NOTIFICATION_TEST_CONSTANTS.ERROR_PUBLISHER_NOT_FOUND)); + + await expect(subscriptionService.getAll()).rejects.toThrow( + NOTIFICATION_TEST_CONSTANTS.ERROR_PUBLISHER_NOT_FOUND + ); + }); + }); + + describe('getPublishers', () => { + it('should GET GetPublishers without params when no name filter', async () => { + const mockData = { publishers: [createBasicSubscriptionPublisher()] }; + mockApiClient.get.mockResolvedValue(mockData); + + const result = await subscriptionService.getPublishers(); + + expect(mockApiClient.get).toHaveBeenCalledWith(SUBSCRIPTION_ENDPOINTS.GET_PUBLISHERS, {}); + expect(result).toEqual(mockData); + }); + + it('should GET GetPublishers with PublisherName param when name supplied', async () => { + const mockData = { publishers: [createBasicSubscriptionPublisher()] }; + mockApiClient.get.mockResolvedValue(mockData); + + await subscriptionService.getPublishers({ name: NOTIFICATION_TEST_CONSTANTS.PUBLISHER_NAME }); + + expect(mockApiClient.get).toHaveBeenCalledWith( + SUBSCRIPTION_ENDPOINTS.GET_PUBLISHERS, + { params: { PublisherName: NOTIFICATION_TEST_CONSTANTS.PUBLISHER_NAME } } + ); + }); + + it('should propagate errors', async () => { + mockApiClient.get.mockRejectedValue(createMockError(TEST_CONSTANTS.ERROR_MESSAGE)); + + await expect(subscriptionService.getPublishers()).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + }); + + describe('getSupportedChannels', () => { + it('should GET GetSupportedChannelStatus and return channels (InApp not included)', async () => { + const mockData = { channels: createBasicSupportedChannels() }; + mockApiClient.get.mockResolvedValue(mockData); + + const result = await subscriptionService.getSupportedChannels(); + + expect(mockApiClient.get).toHaveBeenCalledWith(SUBSCRIPTION_ENDPOINTS.GET_SUPPORTED_CHANNELS, {}); + expect(result).toEqual(mockData); + // Confirms InApp is intentionally omitted (it's always implicit) + expect(result.channels.length).toBeGreaterThan(0); + expect(result.channels.map(c => c.name)).not.toContain('InApp'); + }); + + it('should propagate errors', async () => { + mockApiClient.get.mockRejectedValue(createMockError(TEST_CONSTANTS.ERROR_MESSAGE)); + + await expect(subscriptionService.getSupportedChannels()).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + }); + + describe('updateTopic', () => { + it('should POST userSubscriptions and echo input', async () => { + mockApiClient.post.mockResolvedValue(undefined); + const subscriptions: TopicSubscriptionUpdate[] = [ + { + topicId: NOTIFICATION_TEST_CONSTANTS.TOPIC_ID, + isSubscribed: false, + notificationMode: NotificationMode.Email, + }, + ]; + + const result = await subscriptionService.updateTopic(subscriptions); + + expect(mockApiClient.post).toHaveBeenCalledWith( + SUBSCRIPTION_ENDPOINTS.UPDATE_TOPIC, + { userSubscriptions: subscriptions }, + expect.any(Object) + ); + expect(result).toEqual({ success: true, data: { subscriptions } }); + }); + + it('should propagate errors', async () => { + mockApiClient.post.mockRejectedValue(createMockError(NOTIFICATION_TEST_CONSTANTS.ERROR_SUBSCRIPTION_INVALID)); + + await expect( + subscriptionService.updateTopic([ + { + topicId: NOTIFICATION_TEST_CONSTANTS.TOPIC_ID, + isSubscribed: true, + notificationMode: NotificationMode.InApp, + }, + ]) + ).rejects.toThrow(NOTIFICATION_TEST_CONSTANTS.ERROR_SUBSCRIPTION_INVALID); + }); + }); + + describe('updateCategory', () => { + it('should POST categorySubscriptions and echo input', async () => { + mockApiClient.post.mockResolvedValue(undefined); + const subscriptions: CategorySubscriptionUpdate[] = [ + { + publisherId: NOTIFICATION_TEST_CONSTANTS.PUBLISHER_ID, + category: NotificationCategory.Error, + isSubscribed: false, + notificationMode: NotificationMode.Email, + }, + ]; + + const result = await subscriptionService.updateCategory(subscriptions); + + expect(mockApiClient.post).toHaveBeenCalledWith( + SUBSCRIPTION_ENDPOINTS.UPDATE_CATEGORY, + { categorySubscriptions: subscriptions }, + expect.any(Object) + ); + expect(result).toEqual({ success: true, data: { subscriptions } }); + }); + + it('should propagate errors', async () => { + mockApiClient.post.mockRejectedValue(createMockError(TEST_CONSTANTS.ERROR_MESSAGE)); + + await expect( + subscriptionService.updateCategory([ + { + publisherId: NOTIFICATION_TEST_CONSTANTS.PUBLISHER_ID, + category: NotificationCategory.Info, + isSubscribed: true, + notificationMode: NotificationMode.InApp, + }, + ]) + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + }); + + describe('updatePublisher', () => { + it('should POST publisherSubscriptions with API-spelling publisherID, echoing clean input', async () => { + mockApiClient.post.mockResolvedValue(undefined); + const subscriptions: PublisherSubscriptionUpdate[] = [ + { publisherId: NOTIFICATION_TEST_CONSTANTS.PUBLISHER_ID, isUserOptIn: false }, + ]; + + const result = await subscriptionService.updatePublisher(subscriptions); + + expect(mockApiClient.post).toHaveBeenCalledWith( + SUBSCRIPTION_ENDPOINTS.UPDATE_PUBLISHER, + { + publisherSubscriptions: [ + { + publisherID: NOTIFICATION_TEST_CONSTANTS.PUBLISHER_ID, + isUserOptIn: false, + entities: undefined, + }, + ], + }, + expect.any(Object) + ); + // Result echoes the SDK-shape input (publisherId, not publisherID) + expect(result).toEqual({ success: true, data: { subscriptions } }); + }); + + it('should preserve entities scoping in the request body', async () => { + mockApiClient.post.mockResolvedValue(undefined); + const subscriptions: PublisherSubscriptionUpdate[] = [ + { + publisherId: NOTIFICATION_TEST_CONSTANTS.PUBLISHER_ID, + isUserOptIn: true, + entities: [{ id: 'folder-1', type: 'Folder', isSubscribed: true }], + }, + ]; + + await subscriptionService.updatePublisher(subscriptions); + + expect(mockApiClient.post).toHaveBeenCalledWith( + SUBSCRIPTION_ENDPOINTS.UPDATE_PUBLISHER, + { + publisherSubscriptions: [ + { + publisherID: NOTIFICATION_TEST_CONSTANTS.PUBLISHER_ID, + isUserOptIn: true, + entities: [{ id: 'folder-1', type: 'Folder', isSubscribed: true }], + }, + ], + }, + expect.any(Object) + ); + }); + + it('should propagate errors', async () => { + mockApiClient.post.mockRejectedValue(createMockError(TEST_CONSTANTS.ERROR_MESSAGE)); + + await expect( + subscriptionService.updatePublisher([ + { publisherId: NOTIFICATION_TEST_CONSTANTS.PUBLISHER_ID, isUserOptIn: true }, + ]) + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + }); + + describe('updateTopicGroup', () => { + it('should POST topicGroupSubscriptions and echo input', async () => { + mockApiClient.post.mockResolvedValue(undefined); + const subscriptions: TopicGroupSubscriptionUpdate[] = [ + { + publisherId: NOTIFICATION_TEST_CONSTANTS.PUBLISHER_ID, + topicGroupName: 'JobNotifications', + }, + ]; + + const result = await subscriptionService.updateTopicGroup(subscriptions); + + expect(mockApiClient.post).toHaveBeenCalledWith( + SUBSCRIPTION_ENDPOINTS.UPDATE_TOPIC_GROUP, + { topicGroupSubscriptions: subscriptions }, + expect.any(Object) + ); + expect(result).toEqual({ success: true, data: { subscriptions } }); + }); + + it('should propagate errors', async () => { + mockApiClient.post.mockRejectedValue(createMockError(TEST_CONSTANTS.ERROR_MESSAGE)); + + await expect( + subscriptionService.updateTopicGroup([ + { + publisherId: NOTIFICATION_TEST_CONSTANTS.PUBLISHER_ID, + topicGroupName: 'JobNotifications', + }, + ]) + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + }); + + describe('updateMode', () => { + it('should POST publisherId + publisherMode and echo input', async () => { + mockApiClient.post.mockResolvedValue(undefined); + const mode = { name: NotificationMode.Email, isActive: true }; + + const result = await subscriptionService.updateMode( + NOTIFICATION_TEST_CONSTANTS.PUBLISHER_ID, + mode + ); + + expect(mockApiClient.post).toHaveBeenCalledWith( + SUBSCRIPTION_ENDPOINTS.UPDATE_MODE, + { publisherId: NOTIFICATION_TEST_CONSTANTS.PUBLISHER_ID, publisherMode: mode }, + expect.any(Object) + ); + expect(result).toEqual({ + success: true, + data: { publisherId: NOTIFICATION_TEST_CONSTANTS.PUBLISHER_ID, mode }, + }); + }); + + it('should support deactivating a mode', async () => { + mockApiClient.post.mockResolvedValue(undefined); + const mode = { name: NotificationMode.Slack, isActive: false }; + + await subscriptionService.updateMode(NOTIFICATION_TEST_CONSTANTS.PUBLISHER_ID, mode); + + expect(mockApiClient.post).toHaveBeenCalledWith( + SUBSCRIPTION_ENDPOINTS.UPDATE_MODE, + { publisherId: NOTIFICATION_TEST_CONSTANTS.PUBLISHER_ID, publisherMode: mode }, + expect.any(Object) + ); + }); + + it('should propagate errors', async () => { + mockApiClient.post.mockRejectedValue(createMockError(TEST_CONSTANTS.ERROR_MESSAGE)); + + await expect( + subscriptionService.updateMode(NOTIFICATION_TEST_CONSTANTS.PUBLISHER_ID, { + name: NotificationMode.InApp, + isActive: true, + }) + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + }); + + describe('reset', () => { + it('should POST publisherId and return the publisher subscription state', async () => { + const mockData = { publishers: [createBasicSubscriptionPublisher()] }; + mockApiClient.post.mockResolvedValue(mockData); + + const result = await subscriptionService.reset(NOTIFICATION_TEST_CONSTANTS.PUBLISHER_ID); + + expect(mockApiClient.post).toHaveBeenCalledWith( + SUBSCRIPTION_ENDPOINTS.RESET, + { publisherId: NOTIFICATION_TEST_CONSTANTS.PUBLISHER_ID }, + expect.any(Object) + ); + expect(result).toEqual(mockData); + }); + + it('should propagate errors', async () => { + mockApiClient.post.mockRejectedValue(createMockError(NOTIFICATION_TEST_CONSTANTS.ERROR_PUBLISHER_NOT_FOUND)); + + await expect( + subscriptionService.reset(NOTIFICATION_TEST_CONSTANTS.PUBLISHER_ID) + ).rejects.toThrow(NOTIFICATION_TEST_CONSTANTS.ERROR_PUBLISHER_NOT_FOUND); + }); + }); +}); diff --git a/tests/utils/constants/index.ts b/tests/utils/constants/index.ts index 80019a4ff..a9d96efe7 100644 --- a/tests/utils/constants/index.ts +++ b/tests/utils/constants/index.ts @@ -17,3 +17,4 @@ export * from './attachments'; export * from './feedback'; export * from './traces'; export * from './governance'; +export * from './notification'; diff --git a/tests/utils/constants/notification.ts b/tests/utils/constants/notification.ts new file mode 100644 index 000000000..6e3c85e59 --- /dev/null +++ b/tests/utils/constants/notification.ts @@ -0,0 +1,38 @@ +/** + * Notification & Subscription test constants. + */ + +export const NOTIFICATION_TEST_CONSTANTS = { + // Notification entry identifiers + NOTIFICATION_ID: '11111111-1111-4111-8111-111111111111', + NOTIFICATION_ID_2: '22222222-2222-4222-8222-222222222222', + NOTIFICATION_ID_3: '33333333-3333-4333-8333-333333333333', + + // Publisher identifiers + PUBLISHER_ID: '44444444-4444-4444-4444-444444444444', + PUBLISHER_NAME: 'Orchestrator', + PUBLISHER_DISPLAY_NAME: 'Orchestrator', + PUBLISHER_NAME_ALT: 'Actions', + + // Topic identifiers + TOPIC_ID: '55555555-5555-4555-8555-555555555555', + TOPIC_NAME: 'Process.JobExecution.Faulted', + TOPIC_KEY_NAME: 'Process.JobExecution.Faulted', + TOPIC_DISPLAY_NAME: 'Job execution faulted', + + // User identifiers + USER_ID: '66666666-6666-4666-8666-666666666666', + + // Notification content (mirrors real-API field shapes captured during onboarding) + MESSAGE: 'Job XYZ failed in folder ABC', + MESSAGE_PARAM: '{"jobId":"","folderName":""}', + REDIRECTION_URL: 'https://alpha.uipath.com/orchestrator_/jobs/', + + // Unix epoch seconds (API returns seconds, not ms) + PUBLISHED_ON: 1780981200, + + // Error messages + ERROR_NOTIFICATION_NOT_FOUND: 'Notification not found', + ERROR_PUBLISHER_NOT_FOUND: 'Publisher not found', + ERROR_SUBSCRIPTION_INVALID: 'Subscription request is invalid', +} as const; diff --git a/tests/utils/mocks/index.ts b/tests/utils/mocks/index.ts index c8dd4e5b9..5a30eacfe 100644 --- a/tests/utils/mocks/index.ts +++ b/tests/utils/mocks/index.ts @@ -21,6 +21,7 @@ export * from './feedback'; export * from './attachments'; export * from './traces'; export * from './governance'; +export * from './notification'; // Re-export constants for convenience export * from '../constants'; \ No newline at end of file diff --git a/tests/utils/mocks/notification.ts b/tests/utils/mocks/notification.ts new file mode 100644 index 000000000..9ec22bbd6 --- /dev/null +++ b/tests/utils/mocks/notification.ts @@ -0,0 +1,109 @@ +/** + * Notification & Subscription mock factories. + * + * Shapes mirror the real API responses captured live during onboarding (NOT the + * Swagger spec, which omits some nullable behaviour). + */ + +import { + NotificationCategory, + NotificationMode, + NotificationPriority, +} from '../../../src/models/notification'; +import type { + SubscriptionMode, + SubscriptionPublisher, + SubscriptionTopic, + SupportedChannel, +} from '../../../src/models/notification/subscriptions.types'; +import type { RawNotificationEntry } from '../../../src/models/notification/notifications.internal-types'; +import { NOTIFICATION_TEST_CONSTANTS } from '../constants/notification'; + +/** + * Builds a raw notification entry mirroring a live API response. + */ +export const createBasicNotificationEntry = ( + overrides?: Partial +): RawNotificationEntry => ({ + id: NOTIFICATION_TEST_CONSTANTS.NOTIFICATION_ID, + message: NOTIFICATION_TEST_CONSTANTS.MESSAGE, + isRead: false, + publisherName: NOTIFICATION_TEST_CONSTANTS.PUBLISHER_NAME, + publisherId: NOTIFICATION_TEST_CONSTANTS.PUBLISHER_ID, + topicName: NOTIFICATION_TEST_CONSTANTS.TOPIC_NAME, + topicKeyName: NOTIFICATION_TEST_CONSTANTS.TOPIC_KEY_NAME, + topicId: NOTIFICATION_TEST_CONSTANTS.TOPIC_ID, + userId: NOTIFICATION_TEST_CONSTANTS.USER_ID, + userEmail: null, + tenantId: null, + priority: NotificationPriority.High, + category: NotificationCategory.Error, + messageParam: NOTIFICATION_TEST_CONSTANTS.MESSAGE_PARAM, + redirectionUrl: NOTIFICATION_TEST_CONSTANTS.REDIRECTION_URL, + publishedOn: NOTIFICATION_TEST_CONSTANTS.PUBLISHED_ON, + // Internal fields the API includes but the SDK drops: + entityOrgName: null, + entityTenantName: null, + serviceRegistryName: null, + messageTemplateKey: null, + messageVersion: 1, + publicationId: '00000000-0000-0000-0000-000000000000', + correlationId: null, + partitionKey: 'testorg|testtenant|partition-key-value', + ...overrides, +}); + +const defaultSubscriptionMode = (name: NotificationMode, isSubscribed = true): SubscriptionMode => ({ + name, + isSubscribed, + isSubscribedByDefault: isSubscribed, +}); + +export const createBasicSubscriptionTopic = ( + overrides?: Partial +): SubscriptionTopic => ({ + id: NOTIFICATION_TEST_CONSTANTS.TOPIC_ID, + name: NOTIFICATION_TEST_CONSTANTS.TOPIC_NAME, + displayName: NOTIFICATION_TEST_CONSTANTS.TOPIC_DISPLAY_NAME, + description: 'Job execution faulted', + group: 'Process Activities', + parentGroup: null, + category: NotificationCategory.Error, + isSubscribed: true, + isMandatory: false, + isVisible: true, + isDefault: true, + isAllowedToBeDispatchedInBatch: false, + isInfrequent: false, + retentionDays: 30, + orderingSequence: 1, + modes: [ + defaultSubscriptionMode(NotificationMode.InApp, true), + defaultSubscriptionMode(NotificationMode.Email, true), + ], + ...overrides, +}); + +export const createBasicSubscriptionPublisher = ( + overrides?: Partial +): SubscriptionPublisher => ({ + id: NOTIFICATION_TEST_CONSTANTS.PUBLISHER_ID, + name: NOTIFICATION_TEST_CONSTANTS.PUBLISHER_NAME, + displayName: NOTIFICATION_TEST_CONSTANTS.PUBLISHER_DISPLAY_NAME, + redirectionUrl: 'https://alpha.uipath.com/', + retentionDays: 30, + addToSummary: false, + isUserOptin: true, + modes: [ + { name: NotificationMode.InApp, isActive: true }, + { name: NotificationMode.Email, isActive: true }, + ], + topics: [createBasicSubscriptionTopic()], + ...overrides, +}); + +export const createBasicSupportedChannels = (): SupportedChannel[] => [ + { name: NotificationMode.Email, isEnabled: true }, + { name: NotificationMode.Slack, isEnabled: false }, + { name: NotificationMode.Teams, isEnabled: false }, +]; From fcd9d4362df4c42ef47dc327f8302b36e42eb005 Mon Sep 17 00:00:00 2001 From: sarthak688 <107241313+sarthak688@users.noreply.github.com> Date: Fri, 12 Jun 2026 10:06:44 +0530 Subject: [PATCH 2/2] refactor(notifications): take tenantId as method argument Every public method on Notifications and Subscriptions now takes the tenant GUID as its first positional argument, sent via the X-UIPATH-Internal-TenantId header. Callers no longer configure tenantId on the SDK; the header is built per-call inline. Unit and integration tests + ServiceModel JSDoc updated to match. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../notification/notifications.models.ts | 39 ++++-- .../notification/subscriptions.models.ts | 53 +++++--- src/services/notification/notifications.ts | 55 +++++--- src/services/notification/subscriptions.ts | 80 +++++++---- src/utils/constants/endpoints/notification.ts | 2 +- tests/.env.integration.example | 7 +- tests/integration/config/test-config.ts | 3 + .../notifications.integration.test.ts | 25 ++-- .../subscriptions.integration.test.ts | 29 ++-- .../notification/notifications.test.ts | 59 ++++---- .../notification/subscriptions.test.ts | 128 +++++++++++------- tests/utils/constants/notification.ts | 4 + 12 files changed, 306 insertions(+), 178 deletions(-) diff --git a/src/models/notification/notifications.models.ts b/src/models/notification/notifications.models.ts index a2525a326..ba22fe136 100644 --- a/src/models/notification/notifications.models.ts +++ b/src/models/notification/notifications.models.ts @@ -52,6 +52,10 @@ export type NotificationDeleteAllResponse = OperationResponse<{ /** * Public surface of the Notifications service. JSDoc on this interface drives * the generated API reference documentation. + * + * Every method takes the tenant GUID as the first argument — the notification + * API identifies the acting tenant via the `X-UIPATH-Internal-TenantId` header + * and the SDK forwards `tenantId` into that header on each call. */ export interface NotificationServiceModel { /** @@ -61,6 +65,7 @@ export interface NotificationServiceModel { * when any of `pageSize`/`cursor`/`jumpToPage` is supplied. Supports OData `filter` and * `orderby` query options. * + * @param tenantId - Tenant GUID (sent via `X-UIPATH-Internal-TenantId`) * @param options - Optional OData query and pagination options * @returns Array of notifications, or a paginated result when pagination params are supplied * {@link NotificationGetResponse} @@ -70,12 +75,12 @@ export interface NotificationServiceModel { * import { Notifications } from '@uipath/uipath-typescript/notifications'; * * const notifications = new Notifications(sdk); - * const all = await notifications.getAll(); + * const all = await notifications.getAll(''); * ``` * * @example Filter unread, most recent first * ```typescript - * const unread = await notifications.getAll({ + * const unread = await notifications.getAll('', { * filter: 'isRead eq false', * orderby: 'publishedOn desc', * }); @@ -83,13 +88,14 @@ export interface NotificationServiceModel { * * @example First page with pagination * ```typescript - * const page1 = await notifications.getAll({ pageSize: 20 }); + * const page1 = await notifications.getAll('', { pageSize: 20 }); * if (page1.hasNextPage) { - * const page2 = await notifications.getAll({ cursor: page1.nextCursor }); + * const page2 = await notifications.getAll('', { cursor: page1.nextCursor }); * } * ``` */ getAll( + tenantId: string, options?: T ): Promise< T extends HasPaginationOptions @@ -100,49 +106,53 @@ export interface NotificationServiceModel { /** * Marks the given notifications as read. * + * @param tenantId - Tenant GUID (sent via `X-UIPATH-Internal-TenantId`) * @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.markRead(['', '']); + * await notifications.markRead('', ['', '']); * ``` */ - markRead(notificationIds: string[]): Promise; + markRead(tenantId: string, notificationIds: string[]): Promise; /** * Marks the given notifications as unread. * + * @param tenantId - Tenant GUID (sent via `X-UIPATH-Internal-TenantId`) * @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.markUnread(['']); + * await notifications.markUnread('', ['']); * ``` */ - markUnread(notificationIds: string[]): Promise; + markUnread(tenantId: string, notificationIds: string[]): Promise; /** * Marks all notifications in the current user's inbox as read. * * Uses the server-side `forceAllRead` flag — no per-notification IDs are sent. * + * @param tenantId - Tenant GUID (sent via `X-UIPATH-Internal-TenantId`) * @returns Operation result confirming the bulk update * {@link NotificationMarkAllReadResponse} * * @example * ```typescript - * await notifications.markAllRead(); + * await notifications.markAllRead(''); * ``` */ - markAllRead(): Promise; + markAllRead(tenantId: string): Promise; /** * Deletes the given notifications. * + * @param tenantId - Tenant GUID (sent via `X-UIPATH-Internal-TenantId`) * @param notificationIds - GUIDs of notifications to delete. Must be non-empty — * the server rejects an empty array with HTTP 400. * @returns Operation result echoing the deleted IDs @@ -150,23 +160,24 @@ export interface NotificationServiceModel { * * @example * ```typescript - * await notifications.deleteNotifications(['', '']); + * await notifications.deleteNotifications('', ['', '']); * ``` */ - deleteNotifications(notificationIds: string[]): Promise; + deleteNotifications(tenantId: string, notificationIds: string[]): Promise; /** * Deletes all notifications from the current user's inbox. * * Uses the server-side `deleteAll` flag — no per-notification IDs are sent. * + * @param tenantId - Tenant GUID (sent via `X-UIPATH-Internal-TenantId`) * @returns Operation result confirming the bulk delete * {@link NotificationDeleteAllResponse} * * @example * ```typescript - * await notifications.deleteAll(); + * await notifications.deleteAll(''); * ``` */ - deleteAll(): Promise; + deleteAll(tenantId: string): Promise; } diff --git a/src/models/notification/subscriptions.models.ts b/src/models/notification/subscriptions.models.ts index 6f80ea0a8..c34c176d8 100644 --- a/src/models/notification/subscriptions.models.ts +++ b/src/models/notification/subscriptions.models.ts @@ -66,6 +66,10 @@ export type SubscriptionUpdateModeResponse = OperationResponse<{ /** * Public surface of the Subscriptions service. JSDoc on this interface drives * the generated API reference documentation. + * + * Every method takes the tenant GUID as the first argument — the subscription + * API identifies the acting tenant via the `X-UIPATH-Internal-TenantId` header + * and the SDK forwards `tenantId` into that header on each call. */ export interface SubscriptionServiceModel { /** @@ -76,6 +80,7 @@ export interface SubscriptionServiceModel { * state) the user has access to. Use {@link SubscriptionGetAllOptions.publishers} to * narrow to specific publishers. * + * @param tenantId - Tenant GUID (sent via `X-UIPATH-Internal-TenantId`) * @param options - Optional publisher-name filter * @returns Full subscription state for the matched publishers * {@link SubscriptionGetResponse} @@ -85,17 +90,17 @@ export interface SubscriptionServiceModel { * import { Subscriptions } from '@uipath/uipath-typescript/notifications'; * * const subscriptions = new Subscriptions(sdk); - * const { publishers } = await subscriptions.getAll(); + * const { publishers } = await subscriptions.getAll(''); * ``` * * @example Filter to specific publishers * ```typescript - * const { publishers } = await subscriptions.getAll({ + * const { publishers } = await subscriptions.getAll('', { * publishers: ['Orchestrator', 'Actions'], * }); * ``` */ - getAll(options?: SubscriptionGetAllOptions): Promise; + getAll(tenantId: string, options?: SubscriptionGetAllOptions): Promise; /** * Lists available publishers and their topics, regardless of the user's current subscription. @@ -106,21 +111,22 @@ export interface SubscriptionServiceModel { * Note: the response from this endpoint carries only identity/discovery fields on * publishers and topics (no subscription state). Use {@link getAll} to inspect state. * + * @param tenantId - Tenant GUID (sent via `X-UIPATH-Internal-TenantId`) * @param options - Optional publisher-name filter * @returns Publishers and their full topic catalogue * {@link SubscriptionGetResponse} * * @example Basic usage * ```typescript - * const { publishers } = await subscriptions.getPublishers(); + * const { publishers } = await subscriptions.getPublishers(''); * ``` * * @example Filter to a single publisher * ```typescript - * const { publishers } = await subscriptions.getPublishers({ name: 'Orchestrator' }); + * const { publishers } = await subscriptions.getPublishers('', { name: 'Orchestrator' }); * ``` */ - getPublishers(options?: SubscriptionGetPublishersOptions): Promise; + getPublishers(tenantId: string, options?: SubscriptionGetPublishersOptions): Promise; /** * Gets the notification channels supported in the current tenant. @@ -130,24 +136,26 @@ export interface SubscriptionServiceModel { * * Note: `InApp` is always available and is not included in the response. * + * @param tenantId - Tenant GUID (sent via `X-UIPATH-Internal-TenantId`) * @returns Supported channels with enabled status * {@link SubscriptionGetSupportedChannelsResponse} * * @example * ```typescript - * const { channels } = await subscriptions.getSupportedChannels(); + * const { channels } = await subscriptions.getSupportedChannels(''); * const slack = channels.find(c => c.name === 'Slack'); * if (slack?.isEnabled) { * // safe to subscribe to Slack * } * ``` */ - getSupportedChannels(): Promise; + getSupportedChannels(tenantId: string): Promise; /** * Updates topic-level subscription preferences. Each entry sets the subscription state * (`isSubscribed`) for a single (topic, mode) pair. * + * @param tenantId - Tenant GUID (sent via `X-UIPATH-Internal-TenantId`) * @param subscriptions - Topic subscription updates * @returns Operation result echoing the submitted updates * {@link SubscriptionUpdateTopicResponse} @@ -156,17 +164,18 @@ export interface SubscriptionServiceModel { * ```typescript * import { NotificationMode } from '@uipath/uipath-typescript/notifications'; * - * await subscriptions.updateTopic([ + * await subscriptions.updateTopic('', [ * { topicId: '', isSubscribed: false, notificationMode: NotificationMode.Email }, * ]); * ``` */ - updateTopic(subscriptions: TopicSubscriptionUpdate[]): Promise; + updateTopic(tenantId: string, subscriptions: TopicSubscriptionUpdate[]): Promise; /** * Updates category-level subscription preferences for a publisher. Each entry sets * the subscription state for all topics of a given category via a given mode. * + * @param tenantId - Tenant GUID (sent via `X-UIPATH-Internal-TenantId`) * @param subscriptions - Category subscription updates * @returns Operation result echoing the submitted updates * {@link SubscriptionUpdateCategoryResponse} @@ -175,7 +184,7 @@ export interface SubscriptionServiceModel { * ```typescript * import { NotificationCategory, NotificationMode } from '@uipath/uipath-typescript/notifications'; * - * await subscriptions.updateCategory([ + * await subscriptions.updateCategory('', [ * { * publisherId: '', * category: NotificationCategory.Error, @@ -185,36 +194,38 @@ export interface SubscriptionServiceModel { * ]); * ``` */ - updateCategory(subscriptions: CategorySubscriptionUpdate[]): Promise; + updateCategory(tenantId: string, subscriptions: CategorySubscriptionUpdate[]): Promise; /** * Updates publisher-level opt-in / opt-out. Each entry toggles the user's overall * opt-in for a publisher and optionally scopes the change to specific entities. * + * @param tenantId - Tenant GUID (sent via `X-UIPATH-Internal-TenantId`) * @param subscriptions - Publisher subscription updates * @returns Operation result echoing the submitted updates * {@link SubscriptionUpdatePublisherResponse} * * @example Opt out of a publisher entirely * ```typescript - * await subscriptions.updatePublisher([ + * await subscriptions.updatePublisher('', [ * { publisherId: '', isUserOptIn: false }, * ]); * ``` */ - updatePublisher(subscriptions: PublisherSubscriptionUpdate[]): Promise; + updatePublisher(tenantId: string, subscriptions: PublisherSubscriptionUpdate[]): Promise; /** * Updates topic-group subscription preferences. Each entry scopes a topic group to * a specific set of entities. * + * @param tenantId - Tenant GUID (sent via `X-UIPATH-Internal-TenantId`) * @param subscriptions - Topic-group subscription updates * @returns Operation result echoing the submitted updates * {@link SubscriptionUpdateTopicGroupResponse} * * @example Subscribe a topic group to two folders * ```typescript - * await subscriptions.updateTopicGroup([ + * await subscriptions.updateTopicGroup('', [ * { * publisherId: '', * topicGroupName: 'JobNotifications', @@ -226,13 +237,14 @@ export interface SubscriptionServiceModel { * ]); * ``` */ - updateTopicGroup(subscriptions: TopicGroupSubscriptionUpdate[]): Promise; + updateTopicGroup(tenantId: string, subscriptions: TopicGroupSubscriptionUpdate[]): Promise; /** * Activates or deactivates a notification channel for a publisher. * * Use {@link getSupportedChannels} first to check whether the channel is enabled in the tenant. * + * @param tenantId - Tenant GUID (sent via `X-UIPATH-Internal-TenantId`) * @param publisherId - Publisher GUID * @param mode - Channel and target activation state * @returns Operation result echoing the new mode state @@ -242,25 +254,26 @@ export interface SubscriptionServiceModel { * ```typescript * import { NotificationMode } from '@uipath/uipath-typescript/notifications'; * - * await subscriptions.updateMode('', { + * await subscriptions.updateMode('', '', { * name: NotificationMode.Email, * isActive: true, * }); * ``` */ - updateMode(publisherId: string, mode: AllowedMode): Promise; + updateMode(tenantId: string, publisherId: string, mode: AllowedMode): Promise; /** * Resets the current user's subscriptions for a publisher to the publisher's defaults. * + * @param tenantId - Tenant GUID (sent via `X-UIPATH-Internal-TenantId`) * @param publisherId - Publisher GUID * @returns The publisher's full subscription state after reset * {@link SubscriptionGetResponse} * * @example * ```typescript - * const { publishers } = await subscriptions.reset(''); + * const { publishers } = await subscriptions.reset('', ''); * ``` */ - reset(publisherId: string): Promise; + reset(tenantId: string, publisherId: string): Promise; } diff --git a/src/services/notification/notifications.ts b/src/services/notification/notifications.ts index 8d4fc1394..256832cd9 100644 --- a/src/services/notification/notifications.ts +++ b/src/services/notification/notifications.ts @@ -23,6 +23,8 @@ import { import { ODATA_OFFSET_PARAMS, ODATA_PAGINATION } from '../../utils/constants/common'; import { NOTIFICATION_ENDPOINTS } from '../../utils/constants/endpoints'; +import { TENANT_ID } from '../../utils/constants/headers'; +import { createHeaders } from '../../utils/http/headers'; import { HasPaginationOptions, NonPaginatedResponse, @@ -36,6 +38,10 @@ import { PaginationType } from '../../utils/pagination/internal-types'; * * Provides list / mark-read / delete 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` + * header and the SDK forwards `tenantId` into that header on each call. */ export class NotificationService extends BaseService implements NotificationServiceModel { /** @@ -45,6 +51,7 @@ export class NotificationService extends BaseService implements NotificationServ * when any of `pageSize`/`cursor`/`jumpToPage` is supplied. Supports OData `filter` and * `orderby` query options. * + * @param tenantId - Tenant GUID (sent via `X-UIPATH-Internal-TenantId`) * @param options - Optional OData query and pagination options * @returns Array of notifications, or a paginated result when pagination params are supplied * {@link NotificationGetResponse} @@ -54,12 +61,12 @@ export class NotificationService extends BaseService implements NotificationServ * import { Notifications } from '@uipath/uipath-typescript/notifications'; * * const notifications = new Notifications(sdk); - * const all = await notifications.getAll(); + * const all = await notifications.getAll(''); * ``` * * @example Filter unread, most recent first * ```typescript - * const unread = await notifications.getAll({ + * const unread = await notifications.getAll('', { * filter: 'isRead eq false', * orderby: 'publishedOn desc', * }); @@ -67,14 +74,15 @@ export class NotificationService extends BaseService implements NotificationServ * * @example First page with pagination * ```typescript - * const page1 = await notifications.getAll({ pageSize: 20 }); + * const page1 = await notifications.getAll('', { pageSize: 20 }); * if (page1.hasNextPage) { - * const page2 = await notifications.getAll({ cursor: page1.nextCursor }); + * const page2 = await notifications.getAll('', { cursor: page1.nextCursor }); * } * ``` */ @track('Notifications.GetAll') async getAll( + tenantId: string, options?: T ): Promise< T extends HasPaginationOptions @@ -84,6 +92,7 @@ export class NotificationService extends BaseService implements NotificationServ return PaginationHelpers.getAll({ serviceAccess: this.createPaginationServiceAccess(), getEndpoint: () => NOTIFICATION_ENDPOINTS.GET_ALL, + headers: createHeaders({ [TENANT_ID]: tenantId }), transformFn: stripInternalNotificationFields, pagination: { paginationType: PaginationType.OFFSET, @@ -105,35 +114,37 @@ export class NotificationService extends BaseService implements NotificationServ /** * Marks the given notifications as read. * + * @param tenantId - Tenant GUID (sent via `X-UIPATH-Internal-TenantId`) * @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.markRead(['', '']); + * await notifications.markRead('', ['', '']); * ``` */ @track('Notifications.MarkRead') - async markRead(notificationIds: string[]): Promise { - return this.updateRead(notificationIds, true); + async markRead(tenantId: string, notificationIds: string[]): Promise { + return this.updateRead(tenantId, notificationIds, true); } /** * Marks the given notifications as unread. * + * @param tenantId - Tenant GUID (sent via `X-UIPATH-Internal-TenantId`) * @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.markUnread(['']); + * await notifications.markUnread('', ['']); * ``` */ @track('Notifications.MarkUnread') - async markUnread(notificationIds: string[]): Promise { - return this.updateRead(notificationIds, false); + async markUnread(tenantId: string, notificationIds: string[]): Promise { + return this.updateRead(tenantId, notificationIds, false); } /** @@ -141,26 +152,28 @@ export class NotificationService extends BaseService implements NotificationServ * * Uses the server-side `forceAllRead` flag — no per-notification IDs are sent. * + * @param tenantId - Tenant GUID (sent via `X-UIPATH-Internal-TenantId`) * @returns Operation result confirming the bulk update * {@link NotificationMarkAllReadResponse} * * @example * ```typescript - * await notifications.markAllRead(); + * await notifications.markAllRead(''); * ``` */ @track('Notifications.MarkAllRead') - async markAllRead(): Promise { + async markAllRead(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 } }; } /** * Deletes the given notifications. * + * @param tenantId - Tenant GUID (sent via `X-UIPATH-Internal-TenantId`) * @param notificationIds - GUIDs of notifications to delete. Must be non-empty — * the server rejects an empty array with HTTP 400. * @returns Operation result echoing the deleted IDs @@ -168,16 +181,16 @@ export class NotificationService extends BaseService implements NotificationServ * * @example * ```typescript - * await notifications.deleteNotifications(['', '']); + * await notifications.deleteNotifications('', ['', '']); * ``` */ @track('Notifications.DeleteNotifications') - async deleteNotifications(notificationIds: string[]): Promise { + async deleteNotifications(tenantId: string, notificationIds: string[]): Promise { await this.post(NOTIFICATION_ENDPOINTS.DELETE_BULK, { // API spec misspells the key as `notifcationIds` — preserve it. notifcationIds: notificationIds, deleteAll: false, - }); + }, { headers: createHeaders({ [TENANT_ID]: tenantId }) }); return { success: true, data: { notificationIds } }; } @@ -186,32 +199,34 @@ export class NotificationService extends BaseService implements NotificationServ * * Uses the server-side `deleteAll` flag — no per-notification IDs are sent. * + * @param tenantId - Tenant GUID (sent via `X-UIPATH-Internal-TenantId`) * @returns Operation result confirming the bulk delete * {@link NotificationDeleteAllResponse} * * @example * ```typescript - * await notifications.deleteAll(); + * await notifications.deleteAll(''); * ``` */ @track('Notifications.DeleteAll') - async deleteAll(): Promise { + async deleteAll(tenantId: string): Promise { await this.post(NOTIFICATION_ENDPOINTS.DELETE_BULK, { // API spec misspells the key as `notifcationIds` — preserve it. notifcationIds: [], deleteAll: true, - }); + }, { headers: createHeaders({ [TENANT_ID]: tenantId }) }); return { success: true, data: { all: 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/services/notification/subscriptions.ts b/src/services/notification/subscriptions.ts index efc30c140..9f37e34be 100644 --- a/src/services/notification/subscriptions.ts +++ b/src/services/notification/subscriptions.ts @@ -26,6 +26,8 @@ import type { } from '../../models/notification/subscriptions.models'; import { SUBSCRIPTION_ENDPOINTS } from '../../utils/constants/endpoints'; +import { TENANT_ID } from '../../utils/constants/headers'; +import { createHeaders } from '../../utils/http/headers'; /** * Service for managing the current user's notification subscription preferences. @@ -33,6 +35,10 @@ import { SUBSCRIPTION_ENDPOINTS } from '../../utils/constants/endpoints'; * Subscriptions are scoped to publishers (e.g. `Orchestrator`, `Actions`) and topics * within them. Each topic can be activated per notification channel (InApp, Email, * Slack, Teams) — see {@link NotificationMode}. + * + * Every public method takes the acting tenant GUID as the first argument — the + * subscription API identifies the tenant via the `X-UIPATH-Internal-TenantId` + * header and the SDK forwards `tenantId` into that header on each call. */ export class SubscriptionService extends BaseService implements SubscriptionServiceModel { /** @@ -43,6 +49,7 @@ export class SubscriptionService extends BaseService implements SubscriptionServ * state) the user has access to. Use {@link SubscriptionGetAllOptions.publishers} to * narrow to specific publishers. * + * @param tenantId - Tenant GUID (sent via `X-UIPATH-Internal-TenantId`) * @param options - Optional publisher-name filter * @returns Full subscription state for the matched publishers * {@link SubscriptionGetResponse} @@ -52,21 +59,24 @@ export class SubscriptionService extends BaseService implements SubscriptionServ * import { Subscriptions } from '@uipath/uipath-typescript/notifications'; * * const subscriptions = new Subscriptions(sdk); - * const { publishers } = await subscriptions.getAll(); + * const { publishers } = await subscriptions.getAll(''); * ``` * * @example Filter to specific publishers * ```typescript - * const { publishers } = await subscriptions.getAll({ + * const { publishers } = await subscriptions.getAll('', { * publishers: ['Orchestrator', 'Actions'], * }); * ``` */ @track('Subscriptions.GetAll') - async getAll(options?: SubscriptionGetAllOptions): Promise { + async getAll(tenantId: string, options?: SubscriptionGetAllOptions): Promise { const response = await this.get( SUBSCRIPTION_ENDPOINTS.GET_ALL, - options?.publishers ? { params: { Publishers: options.publishers } } : undefined + { + headers: createHeaders({ [TENANT_ID]: tenantId }), + ...(options?.publishers ? { params: { Publishers: options.publishers } } : {}), + } ); return response.data; } @@ -80,25 +90,29 @@ export class SubscriptionService extends BaseService implements SubscriptionServ * Note: the response from this endpoint carries only identity/discovery fields on * publishers and topics (no subscription state). Use {@link getAll} to inspect state. * + * @param tenantId - Tenant GUID (sent via `X-UIPATH-Internal-TenantId`) * @param options - Optional publisher-name filter * @returns Publishers and their full topic catalogue * {@link SubscriptionGetResponse} * * @example Basic usage * ```typescript - * const { publishers } = await subscriptions.getPublishers(); + * const { publishers } = await subscriptions.getPublishers(''); * ``` * * @example Filter to a single publisher * ```typescript - * const { publishers } = await subscriptions.getPublishers({ name: 'Orchestrator' }); + * const { publishers } = await subscriptions.getPublishers('', { name: 'Orchestrator' }); * ``` */ @track('Subscriptions.GetPublishers') - async getPublishers(options?: SubscriptionGetPublishersOptions): Promise { + async getPublishers(tenantId: string, options?: SubscriptionGetPublishersOptions): Promise { const response = await this.get( SUBSCRIPTION_ENDPOINTS.GET_PUBLISHERS, - options?.name ? { params: { PublisherName: options.name } } : undefined + { + headers: createHeaders({ [TENANT_ID]: tenantId }), + ...(options?.name ? { params: { PublisherName: options.name } } : {}), + } ); return response.data; } @@ -111,12 +125,13 @@ export class SubscriptionService extends BaseService implements SubscriptionServ * * Note: `InApp` is always available and is not included in the response. * + * @param tenantId - Tenant GUID (sent via `X-UIPATH-Internal-TenantId`) * @returns Supported channels with enabled status * {@link SubscriptionGetSupportedChannelsResponse} * * @example * ```typescript - * const { channels } = await subscriptions.getSupportedChannels(); + * const { channels } = await subscriptions.getSupportedChannels(''); * const slack = channels.find(c => c.name === 'Slack'); * if (slack?.isEnabled) { * // safe to subscribe to Slack @@ -124,9 +139,10 @@ export class SubscriptionService extends BaseService implements SubscriptionServ * ``` */ @track('Subscriptions.GetSupportedChannels') - async getSupportedChannels(): Promise { + async getSupportedChannels(tenantId: string): Promise { const response = await this.get( - SUBSCRIPTION_ENDPOINTS.GET_SUPPORTED_CHANNELS + SUBSCRIPTION_ENDPOINTS.GET_SUPPORTED_CHANNELS, + { headers: createHeaders({ [TENANT_ID]: tenantId }) } ); return response.data; } @@ -135,6 +151,7 @@ export class SubscriptionService extends BaseService implements SubscriptionServ * Updates topic-level subscription preferences. Each entry sets the subscription state * (`isSubscribed`) for a single (topic, mode) pair. * + * @param tenantId - Tenant GUID (sent via `X-UIPATH-Internal-TenantId`) * @param subscriptions - Topic subscription updates * @returns Operation result echoing the submitted updates * {@link SubscriptionUpdateTopicResponse} @@ -143,16 +160,16 @@ export class SubscriptionService extends BaseService implements SubscriptionServ * ```typescript * import { NotificationMode } from '@uipath/uipath-typescript/notifications'; * - * await subscriptions.updateTopic([ + * await subscriptions.updateTopic('', [ * { topicId: '', isSubscribed: false, notificationMode: NotificationMode.Email }, * ]); * ``` */ @track('Subscriptions.UpdateTopic') - async updateTopic(subscriptions: TopicSubscriptionUpdate[]): Promise { + async updateTopic(tenantId: string, subscriptions: TopicSubscriptionUpdate[]): Promise { await this.post(SUBSCRIPTION_ENDPOINTS.UPDATE_TOPIC, { userSubscriptions: subscriptions, - }); + }, { headers: createHeaders({ [TENANT_ID]: tenantId }) }); return { success: true, data: { subscriptions } }; } @@ -160,6 +177,7 @@ export class SubscriptionService extends BaseService implements SubscriptionServ * Updates category-level subscription preferences for a publisher. Each entry sets * the subscription state for all topics of a given category via a given mode. * + * @param tenantId - Tenant GUID (sent via `X-UIPATH-Internal-TenantId`) * @param subscriptions - Category subscription updates * @returns Operation result echoing the submitted updates * {@link SubscriptionUpdateCategoryResponse} @@ -168,7 +186,7 @@ export class SubscriptionService extends BaseService implements SubscriptionServ * ```typescript * import { NotificationCategory, NotificationMode } from '@uipath/uipath-typescript/notifications'; * - * await subscriptions.updateCategory([ + * await subscriptions.updateCategory('', [ * { * publisherId: '', * category: NotificationCategory.Error, @@ -179,10 +197,10 @@ export class SubscriptionService extends BaseService implements SubscriptionServ * ``` */ @track('Subscriptions.UpdateCategory') - async updateCategory(subscriptions: CategorySubscriptionUpdate[]): Promise { + async updateCategory(tenantId: string, subscriptions: CategorySubscriptionUpdate[]): Promise { await this.post(SUBSCRIPTION_ENDPOINTS.UPDATE_CATEGORY, { categorySubscriptions: subscriptions, - }); + }, { headers: createHeaders({ [TENANT_ID]: tenantId }) }); return { success: true, data: { subscriptions } }; } @@ -190,19 +208,20 @@ export class SubscriptionService extends BaseService implements SubscriptionServ * Updates publisher-level opt-in / opt-out. Each entry toggles the user's overall * opt-in for a publisher and optionally scopes the change to specific entities. * + * @param tenantId - Tenant GUID (sent via `X-UIPATH-Internal-TenantId`) * @param subscriptions - Publisher subscription updates * @returns Operation result echoing the submitted updates * {@link SubscriptionUpdatePublisherResponse} * * @example Opt out of a publisher entirely * ```typescript - * await subscriptions.updatePublisher([ + * await subscriptions.updatePublisher('', [ * { publisherId: '', isUserOptIn: false }, * ]); * ``` */ @track('Subscriptions.UpdatePublisher') - async updatePublisher(subscriptions: PublisherSubscriptionUpdate[]): Promise { + async updatePublisher(tenantId: string, subscriptions: PublisherSubscriptionUpdate[]): Promise { // API field is misspelled `publisherID` — map at send time. await this.post(SUBSCRIPTION_ENDPOINTS.UPDATE_PUBLISHER, { publisherSubscriptions: subscriptions.map(({ publisherId, isUserOptIn, entities }) => ({ @@ -210,7 +229,7 @@ export class SubscriptionService extends BaseService implements SubscriptionServ isUserOptIn, entities, })), - }); + }, { headers: createHeaders({ [TENANT_ID]: tenantId }) }); return { success: true, data: { subscriptions } }; } @@ -218,13 +237,14 @@ export class SubscriptionService extends BaseService implements SubscriptionServ * Updates topic-group subscription preferences. Each entry scopes a topic group to * a specific set of entities. * + * @param tenantId - Tenant GUID (sent via `X-UIPATH-Internal-TenantId`) * @param subscriptions - Topic-group subscription updates * @returns Operation result echoing the submitted updates * {@link SubscriptionUpdateTopicGroupResponse} * * @example Subscribe a topic group to two folders * ```typescript - * await subscriptions.updateTopicGroup([ + * await subscriptions.updateTopicGroup('', [ * { * publisherId: '', * topicGroupName: 'JobNotifications', @@ -237,10 +257,10 @@ export class SubscriptionService extends BaseService implements SubscriptionServ * ``` */ @track('Subscriptions.UpdateTopicGroup') - async updateTopicGroup(subscriptions: TopicGroupSubscriptionUpdate[]): Promise { + async updateTopicGroup(tenantId: string, subscriptions: TopicGroupSubscriptionUpdate[]): Promise { await this.post(SUBSCRIPTION_ENDPOINTS.UPDATE_TOPIC_GROUP, { topicGroupSubscriptions: subscriptions, - }); + }, { headers: createHeaders({ [TENANT_ID]: tenantId }) }); return { success: true, data: { subscriptions } }; } @@ -249,6 +269,7 @@ export class SubscriptionService extends BaseService implements SubscriptionServ * * Use {@link getSupportedChannels} first to check whether the channel is enabled in the tenant. * + * @param tenantId - Tenant GUID (sent via `X-UIPATH-Internal-TenantId`) * @param publisherId - Publisher GUID * @param mode - Channel and target activation state * @returns Operation result echoing the new mode state @@ -258,38 +279,39 @@ export class SubscriptionService extends BaseService implements SubscriptionServ * ```typescript * import { NotificationMode } from '@uipath/uipath-typescript/notifications'; * - * await subscriptions.updateMode('', { + * await subscriptions.updateMode('', '', { * name: NotificationMode.Email, * isActive: true, * }); * ``` */ @track('Subscriptions.UpdateMode') - async updateMode(publisherId: string, mode: AllowedMode): Promise { + async updateMode(tenantId: string, publisherId: string, mode: AllowedMode): Promise { await this.post(SUBSCRIPTION_ENDPOINTS.UPDATE_MODE, { publisherId, publisherMode: mode, - }); + }, { headers: createHeaders({ [TENANT_ID]: tenantId }) }); return { success: true, data: { publisherId, mode } }; } /** * Resets the current user's subscriptions for a publisher to the publisher's defaults. * + * @param tenantId - Tenant GUID (sent via `X-UIPATH-Internal-TenantId`) * @param publisherId - Publisher GUID * @returns The publisher's full subscription state after reset * {@link SubscriptionGetResponse} * * @example * ```typescript - * const { publishers } = await subscriptions.reset(''); + * const { publishers } = await subscriptions.reset('', ''); * ``` */ @track('Subscriptions.Reset') - async reset(publisherId: string): Promise { + async reset(tenantId: string, publisherId: string): Promise { const response = await this.post(SUBSCRIPTION_ENDPOINTS.RESET, { publisherId, - }); + }, { headers: createHeaders({ [TENANT_ID]: tenantId }) }); return response.data; } } diff --git a/src/utils/constants/endpoints/notification.ts b/src/utils/constants/endpoints/notification.ts index 1b9b45c06..05ce5332f 100644 --- a/src/utils/constants/endpoints/notification.ts +++ b/src/utils/constants/endpoints/notification.ts @@ -28,9 +28,9 @@ export const NOTIFICATION_ENDPOINTS = { export const SUBSCRIPTION_ENDPOINTS = { GET_ALL: `${SUBSCRIPTION_API_BASE}/api/v1/UserSubscription`, GET_PUBLISHERS: `${SUBSCRIPTION_API_BASE}/api/v1/UserSubscription/GetPublishers`, + GET_SUPPORTED_CHANNELS: `${SUBSCRIPTION_API_BASE}/api/v1/UserSubscription/GetSupportedChannelStatus`, // Intentional duplicate of GET_ALL: same URL, POST vs GET differentiates the operation. UPDATE_TOPIC: `${SUBSCRIPTION_API_BASE}/api/v1/UserSubscription`, - UPDATE_TOPIC: `${SUBSCRIPTION_API_BASE}/api/v1/UserSubscription`, UPDATE_CATEGORY: `${SUBSCRIPTION_API_BASE}/api/v1/UserSubscription/CategorySubscription`, UPDATE_PUBLISHER: `${SUBSCRIPTION_API_BASE}/api/v1/UserSubscription/PublisherSubscription`, UPDATE_TOPIC_GROUP: `${SUBSCRIPTION_API_BASE}/api/v1/UserSubscription/TopicGroupSubscription`, diff --git a/tests/.env.integration.example b/tests/.env.integration.example index 917af2048..4395228d1 100644 --- a/tests/.env.integration.example +++ b/tests/.env.integration.example @@ -62,4 +62,9 @@ DATA_FABRIC_TEST_ATTACHMENT_FIELD= JOBS_TEST_FOLDER_ID= # Orchestrator attachment ID (UUID) for attachment getById tests -ORCHESTRATOR_ATTACHMENT_ID= \ No newline at end of file +ORCHESTRATOR_ATTACHMENT_ID= + +# Tenant GUID for Notifications/Subscriptions integration tests +# Required for any notification API call — the service identifies the tenant +# via the X-UIPATH-Internal-TenantId header, not the URL path +NOTIFICATION_TEST_TENANT_ID= \ No newline at end of file diff --git a/tests/integration/config/test-config.ts b/tests/integration/config/test-config.ts index da94c12aa..bb048cd9f 100644 --- a/tests/integration/config/test-config.ts +++ b/tests/integration/config/test-config.ts @@ -21,6 +21,7 @@ export interface IntegrationConfig { dataFabricTestAttachmentField?: string; orchestratorAttachmentId?: string; jobsTestFolderId?: string; + notificationTenantId?: string; } function isValidUrl(value: string): boolean { @@ -73,6 +74,7 @@ function validateConfig(rawConfig: Record): IntegrationConfig { dataFabricTestAttachmentField: typeof rawConfig.dataFabricTestAttachmentField === 'string' ? rawConfig.dataFabricTestAttachmentField : undefined, orchestratorAttachmentId: typeof rawConfig.orchestratorAttachmentId === 'string' ? rawConfig.orchestratorAttachmentId : undefined, jobsTestFolderId: typeof rawConfig.jobsTestFolderId === 'string' ? rawConfig.jobsTestFolderId : undefined, + notificationTenantId: typeof rawConfig.notificationTenantId === 'string' ? rawConfig.notificationTenantId : undefined, }; } @@ -109,6 +111,7 @@ export function loadIntegrationConfig(): IntegrationConfig { dataFabricTestAttachmentField: process.env.DATA_FABRIC_TEST_ATTACHMENT_FIELD || undefined, orchestratorAttachmentId: process.env.ORCHESTRATOR_ATTACHMENT_ID || undefined, jobsTestFolderId: process.env.JOBS_TEST_FOLDER_ID || undefined, + notificationTenantId: process.env.NOTIFICATION_TEST_TENANT_ID || undefined, }; cachedConfig = validateConfig(rawConfig); diff --git a/tests/integration/shared/notification/notifications.integration.test.ts b/tests/integration/shared/notification/notifications.integration.test.ts index 4a0f07305..5a5c6b69a 100644 --- a/tests/integration/shared/notification/notifications.integration.test.ts +++ b/tests/integration/shared/notification/notifications.integration.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeAll } from 'vitest'; -import { getServices, setupUnifiedTests, InitMode } from '../../config/unified-setup'; +import { getServices, getTestConfig, setupUnifiedTests, InitMode } from '../../config/unified-setup'; import type { Notifications } from '../../../../src/services/notification'; import { INTERNAL_NOTIFICATION_FIELDS } from '../../../../src/models/notification/notifications.internal-types'; @@ -9,6 +9,7 @@ describe.each(modes)('Notifications - Integration Tests [%s]', (mode) => { setupUnifiedTests(mode); let notifications!: Notifications; + let tenantId!: string; beforeAll(() => { const service = getServices().notifications; @@ -16,18 +17,24 @@ describe.each(modes)('Notifications - Integration Tests [%s]', (mode) => { throw new Error('Notifications service is not registered for this init mode'); } notifications = service; + + const id = getTestConfig().notificationTenantId; + if (!id) { + throw new Error('NOTIFICATION_TEST_TENANT_ID must be set in .env.integration to run notification tests.'); + } + tenantId = id; }); describe('getAll', () => { it('should retrieve notifications without pagination options as a NonPaginatedResponse', async () => { - const result = await notifications.getAll(); + const result = await notifications.getAll(tenantId); expect(result).toBeDefined(); expect(Array.isArray(result.items)).toBe(true); }); it('should retrieve notifications with pagination options as a PaginatedResponse', async () => { - const result = await notifications.getAll({ pageSize: 5 }); + const result = await notifications.getAll(tenantId, { pageSize: 5 }); expect(result).toBeDefined(); expect(Array.isArray(result.items)).toBe(true); @@ -38,7 +45,7 @@ describe.each(modes)('Notifications - Integration Tests [%s]', (mode) => { }); it('should support OData filter and orderby', async () => { - const result = await notifications.getAll({ + const result = await notifications.getAll(tenantId, { filter: 'isRead eq false', orderby: 'publishedOn desc', pageSize: 3, @@ -52,7 +59,7 @@ describe.each(modes)('Notifications - Integration Tests [%s]', (mode) => { }); it('should drop internal API fields (entityOrgName, partitionKey, etc.) from each item', async () => { - const result = await notifications.getAll({ pageSize: 1 }); + const result = await notifications.getAll(tenantId, { pageSize: 1 }); if (result.items.length === 0) { throw new Error( @@ -77,7 +84,7 @@ describe.each(modes)('Notifications - Integration Tests [%s]', (mode) => { describe('mark-read flows', () => { it('should mark a single notification as read and reflect the change via getAll', async () => { - const unread = await notifications.getAll({ filter: 'isRead eq false', pageSize: 1 }); + const unread = await notifications.getAll(tenantId, { filter: 'isRead eq false', pageSize: 1 }); if (unread.items.length === 0) { throw new Error( 'No unread notifications in the inbox — cannot validate markRead. Trigger one on the test tenant.' @@ -85,19 +92,19 @@ describe.each(modes)('Notifications - Integration Tests [%s]', (mode) => { } const target = unread.items[0]; - const mark = await notifications.markRead([target.id]); + const mark = await notifications.markRead(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.markUnread([target.id]); + const restore = await notifications.markUnread(tenantId, [target.id]); expect(restore.success).toBe(true); expect(restore.data.read).toBe(false); }); it('markAllRead should succeed without per-id payload', async () => { - const result = await notifications.markAllRead(); + const result = await notifications.markAllRead(tenantId); expect(result.success).toBe(true); expect(result.data).toEqual({ all: true, read: true }); }); diff --git a/tests/integration/shared/notification/subscriptions.integration.test.ts b/tests/integration/shared/notification/subscriptions.integration.test.ts index a766d388f..b0e134ad9 100644 --- a/tests/integration/shared/notification/subscriptions.integration.test.ts +++ b/tests/integration/shared/notification/subscriptions.integration.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeAll } from 'vitest'; -import { getServices, setupUnifiedTests, InitMode } from '../../config/unified-setup'; +import { getServices, getTestConfig, setupUnifiedTests, InitMode } from '../../config/unified-setup'; import type { Subscriptions } from '../../../../src/services/notification'; import { NotificationMode, type SubscriptionPublisher } from '../../../../src/models/notification'; @@ -9,6 +9,7 @@ describe.each(modes)('Subscriptions - Integration Tests [%s]', (mode) => { setupUnifiedTests(mode); let subscriptions!: Subscriptions; + let tenantId!: string; let firstPublisher!: SubscriptionPublisher; beforeAll(async () => { @@ -18,7 +19,13 @@ describe.each(modes)('Subscriptions - Integration Tests [%s]', (mode) => { } subscriptions = service; - const { publishers } = await subscriptions.getAll(); + const id = getTestConfig().notificationTenantId; + if (!id) { + throw new Error('NOTIFICATION_TEST_TENANT_ID must be set in .env.integration to run subscription tests.'); + } + tenantId = id; + + const { publishers } = await subscriptions.getAll(tenantId); if (publishers.length === 0) { throw new Error('No publishers visible to the test user — cannot run subscription tests.'); } @@ -36,7 +43,7 @@ describe.each(modes)('Subscriptions - Integration Tests [%s]', (mode) => { }); it('should support filtering by publisher names', async () => { - const { publishers } = await subscriptions.getAll({ publishers: [firstPublisher.name] }); + const { publishers } = await subscriptions.getAll(tenantId, { publishers: [firstPublisher.name] }); expect(publishers.length).toBeGreaterThanOrEqual(1); for (const p of publishers) { @@ -47,7 +54,7 @@ describe.each(modes)('Subscriptions - Integration Tests [%s]', (mode) => { describe('getPublishers', () => { it('should list available publishers with discovery-only fields', async () => { - const { publishers } = await subscriptions.getPublishers(); + const { publishers } = await subscriptions.getPublishers(tenantId); expect(Array.isArray(publishers)).toBe(true); expect(publishers.length).toBeGreaterThan(0); @@ -58,7 +65,7 @@ describe.each(modes)('Subscriptions - Integration Tests [%s]', (mode) => { }); it('should support filtering by publisher name', async () => { - const { publishers } = await subscriptions.getPublishers({ name: firstPublisher.name }); + const { publishers } = await subscriptions.getPublishers(tenantId, { name: firstPublisher.name }); expect(publishers.length).toBeGreaterThanOrEqual(1); expect(publishers[0].name).toBe(firstPublisher.name); @@ -67,7 +74,7 @@ describe.each(modes)('Subscriptions - Integration Tests [%s]', (mode) => { describe('getSupportedChannels', () => { it('should return supported channels for the tenant (excluding implicit InApp)', async () => { - const { channels } = await subscriptions.getSupportedChannels(); + const { channels } = await subscriptions.getSupportedChannels(tenantId); expect(Array.isArray(channels)).toBe(true); // InApp is always implicit; the endpoint shouldn't list it @@ -90,13 +97,13 @@ describe.each(modes)('Subscriptions - Integration Tests [%s]', (mode) => { const originalState = mode.isSubscribed; // Flip the state - const flip = await subscriptions.updateTopic([ + const flip = await subscriptions.updateTopic(tenantId, [ { topicId: topic.id, isSubscribed: !originalState, notificationMode: mode.name }, ]); expect(flip.success).toBe(true); // Restore — leave the tenant in its original state - const restore = await subscriptions.updateTopic([ + const restore = await subscriptions.updateTopic(tenantId, [ { topicId: topic.id, isSubscribed: originalState, notificationMode: mode.name }, ]); expect(restore.success).toBe(true); @@ -107,12 +114,12 @@ describe.each(modes)('Subscriptions - Integration Tests [%s]', (mode) => { it('should round-trip a publisher opt-in/out', async () => { const original = firstPublisher.isUserOptin === true; - const flip = await subscriptions.updatePublisher([ + const flip = await subscriptions.updatePublisher(tenantId, [ { publisherId: firstPublisher.id, isUserOptIn: !original }, ]); expect(flip.success).toBe(true); - const restore = await subscriptions.updatePublisher([ + const restore = await subscriptions.updatePublisher(tenantId, [ { publisherId: firstPublisher.id, isUserOptIn: original }, ]); expect(restore.success).toBe(true); @@ -121,7 +128,7 @@ describe.each(modes)('Subscriptions - Integration Tests [%s]', (mode) => { describe('reset', () => { it('should reset publisher subscriptions and return updated state', async () => { - const result = await subscriptions.reset(firstPublisher.id); + const result = await subscriptions.reset(tenantId, firstPublisher.id); expect(Array.isArray(result.publishers)).toBe(true); const resetPub = result.publishers.find((p) => p.id === firstPublisher.id); diff --git a/tests/unit/services/notification/notifications.test.ts b/tests/unit/services/notification/notifications.test.ts index 27e76e52b..6efc230ee 100644 --- a/tests/unit/services/notification/notifications.test.ts +++ b/tests/unit/services/notification/notifications.test.ts @@ -14,6 +14,7 @@ import { } from '../../../utils/mocks'; import { createServiceTestDependencies, createMockApiClient } from '../../../utils/setup'; import { NOTIFICATION_ENDPOINTS } from '../../../../src/utils/constants/endpoints'; +import { TENANT_ID } from '../../../../src/utils/constants/headers'; import { NotificationCategory, NotificationPriority } from '../../../../src/models/notification'; import type { RawNotificationEntry } from '../../../../src/models/notification/notifications.internal-types'; @@ -24,6 +25,9 @@ const mocks = vi.hoisted(() => import('../../../utils/mocks/core')); vi.mock('../../../../src/utils/pagination/helpers', async () => (await mocks).mockPaginationHelpers); +// Shorthand for asserting the tenant header is forwarded on each call +const TENANT_HEADER = { [TENANT_ID]: NOTIFICATION_TEST_CONSTANTS.TENANT_ID }; + // ===== TEST SUITE ===== describe('NotificationService Unit Tests', () => { let notificationService: NotificationService; @@ -43,11 +47,11 @@ describe('NotificationService Unit Tests', () => { }); describe('getAll', () => { - it('should return a list of notifications via PaginationHelpers with OData pagination params', async () => { + it('should return a list of notifications via PaginationHelpers with OData pagination params and tenant header', async () => { const items = [createBasicNotificationEntry()]; vi.mocked(PaginationHelpers.getAll).mockResolvedValue({ items, totalCount: 1 }); - const result = await notificationService.getAll(); + const result = await notificationService.getAll(NOTIFICATION_TEST_CONSTANTS.TENANT_ID); expect(result.items.length).toBe(1); expect(result.totalCount).toBe(1); @@ -56,6 +60,7 @@ describe('NotificationService Unit Tests', () => { expect.objectContaining({ serviceAccess: expect.any(Object), getEndpoint: expect.any(Function), + headers: TENANT_HEADER, transformFn: stripInternalNotificationFields, pagination: expect.objectContaining({ itemsField: 'value', @@ -74,7 +79,7 @@ describe('NotificationService Unit Tests', () => { it('should pass query/pagination options through to PaginationHelpers', async () => { vi.mocked(PaginationHelpers.getAll).mockResolvedValue({ items: [], totalCount: 0 }); - await notificationService.getAll({ + await notificationService.getAll(NOTIFICATION_TEST_CONSTANTS.TENANT_ID, { filter: 'isRead eq false', orderby: 'publishedOn desc', pageSize: 20, @@ -90,19 +95,21 @@ describe('NotificationService Unit Tests', () => { const error = createMockError(TEST_CONSTANTS.ERROR_MESSAGE); vi.mocked(PaginationHelpers.getAll).mockRejectedValue(error); - await expect(notificationService.getAll()).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + await expect( + notificationService.getAll(NOTIFICATION_TEST_CONSTANTS.TENANT_ID) + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); }); }); describe('markRead', () => { - it('should POST per-id read=true entries to UpdateRead endpoint', async () => { + 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.markRead(ids); + const result = await notificationService.markRead(NOTIFICATION_TEST_CONSTANTS.TENANT_ID, ids); expect(mockApiClient.post).toHaveBeenCalledWith( NOTIFICATION_ENDPOINTS.UPDATE_READ, @@ -113,7 +120,7 @@ describe('NotificationService Unit Tests', () => { ], forceAllRead: false, }, - expect.any(Object) + { headers: TENANT_HEADER } ); expect(result).toEqual({ success: true, data: { notificationIds: ids, read: true } }); }); @@ -122,17 +129,17 @@ describe('NotificationService Unit Tests', () => { mockApiClient.post.mockRejectedValue(createMockError(NOTIFICATION_TEST_CONSTANTS.ERROR_NOTIFICATION_NOT_FOUND)); await expect( - notificationService.markRead([NOTIFICATION_TEST_CONSTANTS.NOTIFICATION_ID]) + notificationService.markRead(NOTIFICATION_TEST_CONSTANTS.TENANT_ID, [NOTIFICATION_TEST_CONSTANTS.NOTIFICATION_ID]) ).rejects.toThrow(NOTIFICATION_TEST_CONSTANTS.ERROR_NOTIFICATION_NOT_FOUND); }); }); describe('markUnread', () => { - it('should POST per-id read=false entries to UpdateRead endpoint', async () => { + 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.markUnread(ids); + const result = await notificationService.markUnread(NOTIFICATION_TEST_CONSTANTS.TENANT_ID, ids); expect(mockApiClient.post).toHaveBeenCalledWith( NOTIFICATION_ENDPOINTS.UPDATE_READ, @@ -142,7 +149,7 @@ describe('NotificationService Unit Tests', () => { ], forceAllRead: false, }, - expect.any(Object) + { headers: TENANT_HEADER } ); expect(result).toEqual({ success: true, data: { notificationIds: ids, read: false } }); }); @@ -151,21 +158,21 @@ describe('NotificationService Unit Tests', () => { mockApiClient.post.mockRejectedValue(createMockError(TEST_CONSTANTS.ERROR_MESSAGE)); await expect( - notificationService.markUnread([NOTIFICATION_TEST_CONSTANTS.NOTIFICATION_ID]) + notificationService.markUnread(NOTIFICATION_TEST_CONSTANTS.TENANT_ID, [NOTIFICATION_TEST_CONSTANTS.NOTIFICATION_ID]) ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); }); }); describe('markAllRead', () => { - it('should POST forceAllRead=true with empty notifications array', async () => { + it('should POST forceAllRead=true with empty notifications array and tenant header', async () => { mockApiClient.post.mockResolvedValue(undefined); - const result = await notificationService.markAllRead(); + const result = await notificationService.markAllRead(NOTIFICATION_TEST_CONSTANTS.TENANT_ID); expect(mockApiClient.post).toHaveBeenCalledWith( NOTIFICATION_ENDPOINTS.UPDATE_READ, { notifications: [], forceAllRead: true }, - expect.any(Object) + { headers: TENANT_HEADER } ); expect(result).toEqual({ success: true, data: { all: true, read: true } }); }); @@ -173,24 +180,26 @@ describe('NotificationService Unit Tests', () => { it('should propagate errors', async () => { mockApiClient.post.mockRejectedValue(createMockError(TEST_CONSTANTS.ERROR_MESSAGE)); - await expect(notificationService.markAllRead()).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + await expect( + notificationService.markAllRead(NOTIFICATION_TEST_CONSTANTS.TENANT_ID) + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); }); }); describe('deleteNotifications', () => { - it('should POST notifcationIds (preserving the API typo) and deleteAll=false', async () => { + it('should POST notifcationIds (preserving the API typo), deleteAll=false, and tenant header', async () => { mockApiClient.post.mockResolvedValue(undefined); const ids = [ NOTIFICATION_TEST_CONSTANTS.NOTIFICATION_ID, NOTIFICATION_TEST_CONSTANTS.NOTIFICATION_ID_2, ]; - const result = await notificationService.deleteNotifications(ids); + const result = await notificationService.deleteNotifications(NOTIFICATION_TEST_CONSTANTS.TENANT_ID, ids); expect(mockApiClient.post).toHaveBeenCalledWith( NOTIFICATION_ENDPOINTS.DELETE_BULK, { notifcationIds: ids, deleteAll: false }, - expect.any(Object) + { headers: TENANT_HEADER } ); expect(result).toEqual({ success: true, data: { notificationIds: ids } }); }); @@ -199,21 +208,21 @@ describe('NotificationService Unit Tests', () => { mockApiClient.post.mockRejectedValue(createMockError(TEST_CONSTANTS.ERROR_MESSAGE)); await expect( - notificationService.deleteNotifications([NOTIFICATION_TEST_CONSTANTS.NOTIFICATION_ID]) + notificationService.deleteNotifications(NOTIFICATION_TEST_CONSTANTS.TENANT_ID, [NOTIFICATION_TEST_CONSTANTS.NOTIFICATION_ID]) ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); }); }); describe('deleteAll', () => { - it('should POST deleteAll=true with empty notifcationIds array', async () => { + it('should POST deleteAll=true with empty notifcationIds array and tenant header', async () => { mockApiClient.post.mockResolvedValue(undefined); - const result = await notificationService.deleteAll(); + const result = await notificationService.deleteAll(NOTIFICATION_TEST_CONSTANTS.TENANT_ID); expect(mockApiClient.post).toHaveBeenCalledWith( NOTIFICATION_ENDPOINTS.DELETE_BULK, { notifcationIds: [], deleteAll: true }, - expect.any(Object) + { headers: TENANT_HEADER } ); expect(result).toEqual({ success: true, data: { all: true } }); }); @@ -221,7 +230,9 @@ describe('NotificationService Unit Tests', () => { it('should propagate errors', async () => { mockApiClient.post.mockRejectedValue(createMockError(TEST_CONSTANTS.ERROR_MESSAGE)); - await expect(notificationService.deleteAll()).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + await expect( + notificationService.deleteAll(NOTIFICATION_TEST_CONSTANTS.TENANT_ID) + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); }); }); diff --git a/tests/unit/services/notification/subscriptions.test.ts b/tests/unit/services/notification/subscriptions.test.ts index 537ce2f6a..5a5825087 100644 --- a/tests/unit/services/notification/subscriptions.test.ts +++ b/tests/unit/services/notification/subscriptions.test.ts @@ -11,6 +11,7 @@ import { } from '../../../utils/mocks'; import { createServiceTestDependencies, createMockApiClient } from '../../../utils/setup'; import { SUBSCRIPTION_ENDPOINTS } from '../../../../src/utils/constants/endpoints'; +import { TENANT_ID } from '../../../../src/utils/constants/headers'; import { NotificationCategory, NotificationMode, @@ -23,6 +24,9 @@ import { // ===== MOCKING ===== vi.mock('../../../../src/core/http/api-client'); +// Shorthand for asserting the tenant header is forwarded on each call +const TENANT_HEADER = { [TENANT_ID]: NOTIFICATION_TEST_CONSTANTS.TENANT_ID }; + // ===== TEST SUITE ===== describe('SubscriptionService Unit Tests', () => { let subscriptionService: SubscriptionService; @@ -41,27 +45,30 @@ describe('SubscriptionService Unit Tests', () => { }); describe('getAll', () => { - it('should GET UserSubscription without params when no publishers filter', async () => { + it('should GET UserSubscription with only tenant header when no publishers filter', async () => { const mockData = { publishers: [createBasicSubscriptionPublisher()] }; mockApiClient.get.mockResolvedValue(mockData); - const result = await subscriptionService.getAll(); + const result = await subscriptionService.getAll(NOTIFICATION_TEST_CONSTANTS.TENANT_ID); - expect(mockApiClient.get).toHaveBeenCalledWith(SUBSCRIPTION_ENDPOINTS.GET_ALL, {}); + expect(mockApiClient.get).toHaveBeenCalledWith(SUBSCRIPTION_ENDPOINTS.GET_ALL, { + headers: TENANT_HEADER, + }); expect(result).toEqual(mockData); }); - it('should GET UserSubscription with Publishers param when filter supplied', async () => { + it('should GET UserSubscription with Publishers param + tenant header when filter supplied', async () => { const mockData = { publishers: [createBasicSubscriptionPublisher()] }; mockApiClient.get.mockResolvedValue(mockData); - const result = await subscriptionService.getAll({ + const result = await subscriptionService.getAll(NOTIFICATION_TEST_CONSTANTS.TENANT_ID, { publishers: [NOTIFICATION_TEST_CONSTANTS.PUBLISHER_NAME, NOTIFICATION_TEST_CONSTANTS.PUBLISHER_NAME_ALT], }); expect(mockApiClient.get).toHaveBeenCalledWith( SUBSCRIPTION_ENDPOINTS.GET_ALL, { + headers: TENANT_HEADER, params: { Publishers: [ NOTIFICATION_TEST_CONSTANTS.PUBLISHER_NAME, @@ -76,50 +83,59 @@ describe('SubscriptionService Unit Tests', () => { it('should propagate errors', async () => { mockApiClient.get.mockRejectedValue(createMockError(NOTIFICATION_TEST_CONSTANTS.ERROR_PUBLISHER_NOT_FOUND)); - await expect(subscriptionService.getAll()).rejects.toThrow( - NOTIFICATION_TEST_CONSTANTS.ERROR_PUBLISHER_NOT_FOUND - ); + await expect( + subscriptionService.getAll(NOTIFICATION_TEST_CONSTANTS.TENANT_ID) + ).rejects.toThrow(NOTIFICATION_TEST_CONSTANTS.ERROR_PUBLISHER_NOT_FOUND); }); }); describe('getPublishers', () => { - it('should GET GetPublishers without params when no name filter', async () => { + it('should GET GetPublishers with only tenant header when no name filter', async () => { const mockData = { publishers: [createBasicSubscriptionPublisher()] }; mockApiClient.get.mockResolvedValue(mockData); - const result = await subscriptionService.getPublishers(); + const result = await subscriptionService.getPublishers(NOTIFICATION_TEST_CONSTANTS.TENANT_ID); - expect(mockApiClient.get).toHaveBeenCalledWith(SUBSCRIPTION_ENDPOINTS.GET_PUBLISHERS, {}); + expect(mockApiClient.get).toHaveBeenCalledWith(SUBSCRIPTION_ENDPOINTS.GET_PUBLISHERS, { + headers: TENANT_HEADER, + }); expect(result).toEqual(mockData); }); - it('should GET GetPublishers with PublisherName param when name supplied', async () => { + it('should GET GetPublishers with PublisherName param + tenant header when name supplied', async () => { const mockData = { publishers: [createBasicSubscriptionPublisher()] }; mockApiClient.get.mockResolvedValue(mockData); - await subscriptionService.getPublishers({ name: NOTIFICATION_TEST_CONSTANTS.PUBLISHER_NAME }); + await subscriptionService.getPublishers(NOTIFICATION_TEST_CONSTANTS.TENANT_ID, { + name: NOTIFICATION_TEST_CONSTANTS.PUBLISHER_NAME, + }); expect(mockApiClient.get).toHaveBeenCalledWith( SUBSCRIPTION_ENDPOINTS.GET_PUBLISHERS, - { params: { PublisherName: NOTIFICATION_TEST_CONSTANTS.PUBLISHER_NAME } } + { headers: TENANT_HEADER, params: { PublisherName: NOTIFICATION_TEST_CONSTANTS.PUBLISHER_NAME } } ); }); it('should propagate errors', async () => { mockApiClient.get.mockRejectedValue(createMockError(TEST_CONSTANTS.ERROR_MESSAGE)); - await expect(subscriptionService.getPublishers()).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + await expect( + subscriptionService.getPublishers(NOTIFICATION_TEST_CONSTANTS.TENANT_ID) + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); }); }); describe('getSupportedChannels', () => { - it('should GET GetSupportedChannelStatus and return channels (InApp not included)', async () => { + it('should GET GetSupportedChannelStatus with tenant header and return channels (InApp not included)', async () => { const mockData = { channels: createBasicSupportedChannels() }; mockApiClient.get.mockResolvedValue(mockData); - const result = await subscriptionService.getSupportedChannels(); + const result = await subscriptionService.getSupportedChannels(NOTIFICATION_TEST_CONSTANTS.TENANT_ID); - expect(mockApiClient.get).toHaveBeenCalledWith(SUBSCRIPTION_ENDPOINTS.GET_SUPPORTED_CHANNELS, {}); + expect(mockApiClient.get).toHaveBeenCalledWith( + SUBSCRIPTION_ENDPOINTS.GET_SUPPORTED_CHANNELS, + { headers: TENANT_HEADER } + ); expect(result).toEqual(mockData); // Confirms InApp is intentionally omitted (it's always implicit) expect(result.channels.length).toBeGreaterThan(0); @@ -129,12 +145,14 @@ describe('SubscriptionService Unit Tests', () => { it('should propagate errors', async () => { mockApiClient.get.mockRejectedValue(createMockError(TEST_CONSTANTS.ERROR_MESSAGE)); - await expect(subscriptionService.getSupportedChannels()).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + await expect( + subscriptionService.getSupportedChannels(NOTIFICATION_TEST_CONSTANTS.TENANT_ID) + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); }); }); describe('updateTopic', () => { - it('should POST userSubscriptions and echo input', async () => { + it('should POST userSubscriptions with tenant header and echo input', async () => { mockApiClient.post.mockResolvedValue(undefined); const subscriptions: TopicSubscriptionUpdate[] = [ { @@ -144,12 +162,12 @@ describe('SubscriptionService Unit Tests', () => { }, ]; - const result = await subscriptionService.updateTopic(subscriptions); + const result = await subscriptionService.updateTopic(NOTIFICATION_TEST_CONSTANTS.TENANT_ID, subscriptions); expect(mockApiClient.post).toHaveBeenCalledWith( SUBSCRIPTION_ENDPOINTS.UPDATE_TOPIC, { userSubscriptions: subscriptions }, - expect.any(Object) + { headers: TENANT_HEADER } ); expect(result).toEqual({ success: true, data: { subscriptions } }); }); @@ -158,7 +176,7 @@ describe('SubscriptionService Unit Tests', () => { mockApiClient.post.mockRejectedValue(createMockError(NOTIFICATION_TEST_CONSTANTS.ERROR_SUBSCRIPTION_INVALID)); await expect( - subscriptionService.updateTopic([ + subscriptionService.updateTopic(NOTIFICATION_TEST_CONSTANTS.TENANT_ID, [ { topicId: NOTIFICATION_TEST_CONSTANTS.TOPIC_ID, isSubscribed: true, @@ -170,7 +188,7 @@ describe('SubscriptionService Unit Tests', () => { }); describe('updateCategory', () => { - it('should POST categorySubscriptions and echo input', async () => { + it('should POST categorySubscriptions with tenant header and echo input', async () => { mockApiClient.post.mockResolvedValue(undefined); const subscriptions: CategorySubscriptionUpdate[] = [ { @@ -181,12 +199,12 @@ describe('SubscriptionService Unit Tests', () => { }, ]; - const result = await subscriptionService.updateCategory(subscriptions); + const result = await subscriptionService.updateCategory(NOTIFICATION_TEST_CONSTANTS.TENANT_ID, subscriptions); expect(mockApiClient.post).toHaveBeenCalledWith( SUBSCRIPTION_ENDPOINTS.UPDATE_CATEGORY, { categorySubscriptions: subscriptions }, - expect.any(Object) + { headers: TENANT_HEADER } ); expect(result).toEqual({ success: true, data: { subscriptions } }); }); @@ -195,7 +213,7 @@ describe('SubscriptionService Unit Tests', () => { mockApiClient.post.mockRejectedValue(createMockError(TEST_CONSTANTS.ERROR_MESSAGE)); await expect( - subscriptionService.updateCategory([ + subscriptionService.updateCategory(NOTIFICATION_TEST_CONSTANTS.TENANT_ID, [ { publisherId: NOTIFICATION_TEST_CONSTANTS.PUBLISHER_ID, category: NotificationCategory.Info, @@ -208,13 +226,13 @@ describe('SubscriptionService Unit Tests', () => { }); describe('updatePublisher', () => { - it('should POST publisherSubscriptions with API-spelling publisherID, echoing clean input', async () => { + it('should POST publisherSubscriptions with API-spelling publisherID + tenant header, echoing clean input', async () => { mockApiClient.post.mockResolvedValue(undefined); const subscriptions: PublisherSubscriptionUpdate[] = [ { publisherId: NOTIFICATION_TEST_CONSTANTS.PUBLISHER_ID, isUserOptIn: false }, ]; - const result = await subscriptionService.updatePublisher(subscriptions); + const result = await subscriptionService.updatePublisher(NOTIFICATION_TEST_CONSTANTS.TENANT_ID, subscriptions); expect(mockApiClient.post).toHaveBeenCalledWith( SUBSCRIPTION_ENDPOINTS.UPDATE_PUBLISHER, @@ -227,7 +245,7 @@ describe('SubscriptionService Unit Tests', () => { }, ], }, - expect.any(Object) + { headers: TENANT_HEADER } ); // Result echoes the SDK-shape input (publisherId, not publisherID) expect(result).toEqual({ success: true, data: { subscriptions } }); @@ -243,7 +261,7 @@ describe('SubscriptionService Unit Tests', () => { }, ]; - await subscriptionService.updatePublisher(subscriptions); + await subscriptionService.updatePublisher(NOTIFICATION_TEST_CONSTANTS.TENANT_ID, subscriptions); expect(mockApiClient.post).toHaveBeenCalledWith( SUBSCRIPTION_ENDPOINTS.UPDATE_PUBLISHER, @@ -256,7 +274,7 @@ describe('SubscriptionService Unit Tests', () => { }, ], }, - expect.any(Object) + { headers: TENANT_HEADER } ); }); @@ -264,7 +282,7 @@ describe('SubscriptionService Unit Tests', () => { mockApiClient.post.mockRejectedValue(createMockError(TEST_CONSTANTS.ERROR_MESSAGE)); await expect( - subscriptionService.updatePublisher([ + subscriptionService.updatePublisher(NOTIFICATION_TEST_CONSTANTS.TENANT_ID, [ { publisherId: NOTIFICATION_TEST_CONSTANTS.PUBLISHER_ID, isUserOptIn: true }, ]) ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); @@ -272,7 +290,7 @@ describe('SubscriptionService Unit Tests', () => { }); describe('updateTopicGroup', () => { - it('should POST topicGroupSubscriptions and echo input', async () => { + it('should POST topicGroupSubscriptions with tenant header and echo input', async () => { mockApiClient.post.mockResolvedValue(undefined); const subscriptions: TopicGroupSubscriptionUpdate[] = [ { @@ -281,12 +299,12 @@ describe('SubscriptionService Unit Tests', () => { }, ]; - const result = await subscriptionService.updateTopicGroup(subscriptions); + const result = await subscriptionService.updateTopicGroup(NOTIFICATION_TEST_CONSTANTS.TENANT_ID, subscriptions); expect(mockApiClient.post).toHaveBeenCalledWith( SUBSCRIPTION_ENDPOINTS.UPDATE_TOPIC_GROUP, { topicGroupSubscriptions: subscriptions }, - expect.any(Object) + { headers: TENANT_HEADER } ); expect(result).toEqual({ success: true, data: { subscriptions } }); }); @@ -295,7 +313,7 @@ describe('SubscriptionService Unit Tests', () => { mockApiClient.post.mockRejectedValue(createMockError(TEST_CONSTANTS.ERROR_MESSAGE)); await expect( - subscriptionService.updateTopicGroup([ + subscriptionService.updateTopicGroup(NOTIFICATION_TEST_CONSTANTS.TENANT_ID, [ { publisherId: NOTIFICATION_TEST_CONSTANTS.PUBLISHER_ID, topicGroupName: 'JobNotifications', @@ -306,11 +324,12 @@ describe('SubscriptionService Unit Tests', () => { }); describe('updateMode', () => { - it('should POST publisherId + publisherMode and echo input', async () => { + it('should POST publisherId + publisherMode with tenant header and echo input', async () => { mockApiClient.post.mockResolvedValue(undefined); const mode = { name: NotificationMode.Email, isActive: true }; const result = await subscriptionService.updateMode( + NOTIFICATION_TEST_CONSTANTS.TENANT_ID, NOTIFICATION_TEST_CONSTANTS.PUBLISHER_ID, mode ); @@ -318,7 +337,7 @@ describe('SubscriptionService Unit Tests', () => { expect(mockApiClient.post).toHaveBeenCalledWith( SUBSCRIPTION_ENDPOINTS.UPDATE_MODE, { publisherId: NOTIFICATION_TEST_CONSTANTS.PUBLISHER_ID, publisherMode: mode }, - expect.any(Object) + { headers: TENANT_HEADER } ); expect(result).toEqual({ success: true, @@ -330,12 +349,16 @@ describe('SubscriptionService Unit Tests', () => { mockApiClient.post.mockResolvedValue(undefined); const mode = { name: NotificationMode.Slack, isActive: false }; - await subscriptionService.updateMode(NOTIFICATION_TEST_CONSTANTS.PUBLISHER_ID, mode); + await subscriptionService.updateMode( + NOTIFICATION_TEST_CONSTANTS.TENANT_ID, + NOTIFICATION_TEST_CONSTANTS.PUBLISHER_ID, + mode + ); expect(mockApiClient.post).toHaveBeenCalledWith( SUBSCRIPTION_ENDPOINTS.UPDATE_MODE, { publisherId: NOTIFICATION_TEST_CONSTANTS.PUBLISHER_ID, publisherMode: mode }, - expect.any(Object) + { headers: TENANT_HEADER } ); }); @@ -343,25 +366,32 @@ describe('SubscriptionService Unit Tests', () => { mockApiClient.post.mockRejectedValue(createMockError(TEST_CONSTANTS.ERROR_MESSAGE)); await expect( - subscriptionService.updateMode(NOTIFICATION_TEST_CONSTANTS.PUBLISHER_ID, { - name: NotificationMode.InApp, - isActive: true, - }) + subscriptionService.updateMode( + NOTIFICATION_TEST_CONSTANTS.TENANT_ID, + NOTIFICATION_TEST_CONSTANTS.PUBLISHER_ID, + { + name: NotificationMode.InApp, + isActive: true, + } + ) ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); }); }); describe('reset', () => { - it('should POST publisherId and return the publisher subscription state', async () => { + it('should POST publisherId with tenant header and return the publisher subscription state', async () => { const mockData = { publishers: [createBasicSubscriptionPublisher()] }; mockApiClient.post.mockResolvedValue(mockData); - const result = await subscriptionService.reset(NOTIFICATION_TEST_CONSTANTS.PUBLISHER_ID); + const result = await subscriptionService.reset( + NOTIFICATION_TEST_CONSTANTS.TENANT_ID, + NOTIFICATION_TEST_CONSTANTS.PUBLISHER_ID + ); expect(mockApiClient.post).toHaveBeenCalledWith( SUBSCRIPTION_ENDPOINTS.RESET, { publisherId: NOTIFICATION_TEST_CONSTANTS.PUBLISHER_ID }, - expect.any(Object) + { headers: TENANT_HEADER } ); expect(result).toEqual(mockData); }); @@ -370,7 +400,7 @@ describe('SubscriptionService Unit Tests', () => { mockApiClient.post.mockRejectedValue(createMockError(NOTIFICATION_TEST_CONSTANTS.ERROR_PUBLISHER_NOT_FOUND)); await expect( - subscriptionService.reset(NOTIFICATION_TEST_CONSTANTS.PUBLISHER_ID) + subscriptionService.reset(NOTIFICATION_TEST_CONSTANTS.TENANT_ID, NOTIFICATION_TEST_CONSTANTS.PUBLISHER_ID) ).rejects.toThrow(NOTIFICATION_TEST_CONSTANTS.ERROR_PUBLISHER_NOT_FOUND); }); }); diff --git a/tests/utils/constants/notification.ts b/tests/utils/constants/notification.ts index 6e3c85e59..afc7a8337 100644 --- a/tests/utils/constants/notification.ts +++ b/tests/utils/constants/notification.ts @@ -3,6 +3,10 @@ */ export const NOTIFICATION_TEST_CONSTANTS = { + // Tenant GUID — passed as the first arg to every Notifications/Subscriptions + // method (sent to the API via the X-UIPATH-Internal-TenantId header) + TENANT_ID: '99999999-9999-4999-8999-999999999999', + // Notification entry identifiers NOTIFICATION_ID: '11111111-1111-4111-8111-111111111111', NOTIFICATION_ID_2: '22222222-2222-4222-8222-222222222222',