From 9d136e9208fb0bd310d7b8969b4370a00a86a16c Mon Sep 17 00:00:00 2001 From: sarthak688 <107241313+sarthak688@users.noreply.github.com> Date: Tue, 16 Jun 2026 16:08:41 +0530 Subject: [PATCH] feat(notifications): add Notifications service foundation + getAll [internal] Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 10 + rollup.config.js | 5 + src/models/notification/index.ts | 6 + .../notification/notifications.constants.ts | 9 + .../notifications.internal-types.ts | 58 +++++ .../notification/notifications.models.ts | 69 ++++++ .../notification/notifications.types.ts | 86 +++++++ src/services/notification/index.ts | 29 +++ src/services/notification/notifications.ts | 132 ++++++++++ src/utils/constants/endpoints/base.ts | 11 + src/utils/constants/endpoints/index.ts | 3 + src/utils/constants/endpoints/notification.ts | 18 ++ tests/integration/config/test-config.ts | 3 + tests/integration/config/unified-setup.ts | 3 + .../notifications.integration.test.ts | 124 ++++++++++ .../notification/notifications.test.ts | 234 ++++++++++++++++++ tests/utils/constants/index.ts | 1 + tests/utils/constants/notification.ts | 30 +++ tests/utils/mocks/index.ts | 1 + tests/utils/mocks/notification.ts | 47 ++++ 20 files changed, 879 insertions(+) create mode 100644 src/models/notification/index.ts create mode 100644 src/models/notification/notifications.constants.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/services/notification/index.ts create mode 100644 src/services/notification/notifications.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/unit/services/notification/notifications.test.ts create mode 100644 tests/utils/constants/notification.ts create mode 100644 tests/utils/mocks/notification.ts diff --git a/package.json b/package.json index c6525348d..60a04132a 100644 --- a/package.json +++ b/package.json @@ -215,6 +215,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 aa42cc39b..4904bb9d2 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -228,6 +228,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..037da9f1d --- /dev/null +++ b/src/models/notification/index.ts @@ -0,0 +1,6 @@ +/** + * Notification models barrel export. + */ + +export * from './notifications.types'; +export * from './notifications.models'; diff --git a/src/models/notification/notifications.constants.ts b/src/models/notification/notifications.constants.ts new file mode 100644 index 000000000..ebdd4b68e --- /dev/null +++ b/src/models/notification/notifications.constants.ts @@ -0,0 +1,9 @@ +/** + * Notification field mappings (API field name → SDK field name). + * + * Semantic renames only — case conversion is handled by `pascalToCamelCaseKeys()` + * (not needed here, the API already returns camelCase). + */ +export const NotificationMap: { [key: string]: string } = { + isRead: 'hasRead', +}; diff --git a/src/models/notification/notifications.internal-types.ts b/src/models/notification/notifications.internal-types.ts new file mode 100644 index 000000000..eeac685fe --- /dev/null +++ b/src/models/notification/notifications.internal-types.ts @@ -0,0 +1,58 @@ +/** + * Internal-only types for the Notification service. + * + * NOT exported from the public barrel (`src/models/notification/index.ts`). + */ + +import type { NotificationPriority, NotificationCategory } from './notifications.types'; + +/** + * Raw notification entry shape as returned by `/odata/v1/NotificationEntry`. + * + * Mirrors the API contract exactly: it uses the API's `isRead` field (renamed to + * `hasRead` in the SDK response) and includes the internal/transport-layer fields + * the SDK drops before returning to consumers. + */ +export interface RawNotificationEntry { + id: string; + message: string | null; + /** API read flag — renamed to `hasRead` in the SDK response. */ + isRead: boolean; + publisherName: string; + publisherId: string; + topicName: string; + topicKeyName: string; + topicId: string; + userId: string; + userEmail: string | null; + tenantId: string | null; + priority: NotificationPriority; + category: NotificationCategory; + messageParam: string | null; + redirectionUrl: string | null; + publishedOn: number; + // Internal/transport-layer fields the SDK strips before returning to consumers: + 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 = [ + 'entityOrgName', + 'entityTenantName', + 'serviceRegistryName', + 'messageTemplateKey', + 'messageVersion', + 'publicationId', + 'correlationId', + 'partitionKey', +] as const satisfies ReadonlyArray; diff --git a/src/models/notification/notifications.models.ts b/src/models/notification/notifications.models.ts new file mode 100644 index 000000000..b2bf1bc42 --- /dev/null +++ b/src/models/notification/notifications.models.ts @@ -0,0 +1,69 @@ +/** + * Notification service model — public response shapes and the ServiceModel interface + * that drives generated API documentation. + */ + +import type { + HasPaginationOptions, + NonPaginatedResponse, + PaginatedResponse, +} from '../../utils/pagination/types'; + +import type { + NotificationGetAllOptions, + NotificationGetResponse, +} from './notifications.types'; + +/** + * 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 { + /** + * 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 tenantId - Tenant GUID (sent via `X-UIPATH-Internal-TenantId`) + * @returns Promise resolving to either a {@link NonPaginatedResponse}<{@link NotificationGetResponse}> or a {@link PaginatedResponse}<{@link NotificationGetResponse}> when pagination options are used. + * + * @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: 'hasRead 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 }); + * } + * ``` + * @internal + */ + getAll( + tenantId: string, + options?: T + ): Promise< + T extends HasPaginationOptions + ? PaginatedResponse + : NonPaginatedResponse + >; +} diff --git a/src/models/notification/notifications.types.ts b/src/models/notification/notifications.types.ts new file mode 100644 index 000000000..495c1a900 --- /dev/null +++ b/src/models/notification/notifications.types.ts @@ -0,0 +1,86 @@ +/** + * Notification inbox types — raw API shapes and request/response options. + */ + +import type { RequestOptions } from '../common/types'; +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 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. */ + hasRead: 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 = Omit & PaginationOptions; diff --git a/src/services/notification/index.ts b/src/services/notification/index.ts new file mode 100644 index 000000000..aa7e98761 --- /dev/null +++ b/src/services/notification/index.ts @@ -0,0 +1,29 @@ +/** + * Notification Service Module + * + * Provides access to the UiPath Notification platform from the perspective of an + * authenticated user (UserContext): + * - `Notifications` — list operations on the user's inbox (further operations land in + * follow-up PRs) + * + * 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 } from '@uipath/uipath-typescript/notifications'; + * + * const sdk = new UiPath(config); + * await sdk.initialize(); + * + * const notifications = new Notifications(sdk); + * const unread = await notifications.getAll('', { filter: 'hasRead eq false' }); + * ``` + * + * @module + */ + +export { NotificationService as Notifications } from './notifications'; + +// 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..2e2518ad9 --- /dev/null +++ b/src/services/notification/notifications.ts @@ -0,0 +1,132 @@ +/** + * 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 { + NotificationServiceModel, +} from '../../models/notification/notifications.models'; +import { + INTERNAL_NOTIFICATION_FIELDS, + type RawNotificationEntry, +} from '../../models/notification/notifications.internal-types'; + +import { NotificationMap } from '../../models/notification/notifications.constants'; +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 { transformData, transformOptions } from '../../utils/transform'; +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 operations against the current user's notifications (the + * `/odata/v1/NotificationEntry` API). Further inbox operations (mark-read, + * delete) land in follow-up PRs. + * + * 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 { + /** + * 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 tenantId - Tenant GUID (sent via `X-UIPATH-Internal-TenantId`) + * @param options - Optional OData query and pagination options + * @returns Promise resolving to either a {@link NonPaginatedResponse}<{@link NotificationGetResponse}> or a {@link PaginatedResponse}<{@link NotificationGetResponse}> when pagination options are used. + * + * @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: 'hasRead 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 }); + * } + * ``` + * @internal + */ + @track('Notifications.GetAll') + async getAll( + tenantId: string, + options?: T + ): Promise< + T extends HasPaginationOptions + ? PaginatedResponse + : NonPaginatedResponse + > { + // Rewrite renamed SDK field names → API names inside OData strings + // (e.g. `hasRead` → `isRead`) before delegating, so callers use the same + // field names for filtering/sorting that they see in the response. + const apiOptions = options ? transformOptions(options, NotificationMap) : options; + + return PaginationHelpers.getAll({ + serviceAccess: this.createPaginationServiceAccess(), + getEndpoint: () => NOTIFICATION_ENDPOINTS.GET_ALL, + headers: createHeaders({ [TENANT_ID]: tenantId }), + 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, + }, + }, + }, apiOptions) as Promise< + T extends HasPaginationOptions + ? PaginatedResponse + : NonPaginatedResponse + >; + } +} + +/** + * Drops internal/transport-layer fields from a raw notification entry and applies + * the SDK field renames (e.g. `isRead` → `hasRead`) before returning it to the + * consumer. Exported as module-level for testability. + * + * @internal + */ +export function stripInternalNotificationFields(item: RawNotificationEntry): NotificationGetResponse { + const stripped: RawNotificationEntry = { ...item }; + for (const field of INTERNAL_NOTIFICATION_FIELDS) { + delete stripped[field]; + } + return transformData(stripped, NotificationMap) as unknown as NotificationGetResponse; +} 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 1b9c6c596..aab9ae159 100644 --- a/src/utils/constants/endpoints/index.ts +++ b/src/utils/constants/endpoints/index.ts @@ -38,3 +38,6 @@ export * from './agents'; // 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..4c97f529a --- /dev/null +++ b/src/utils/constants/endpoints/notification.ts @@ -0,0 +1,18 @@ +/** + * Notification Service Endpoints + * + * Inbox endpoints under the `notificationservice_/notificationserviceapi` prefix. + * + * 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`; + +/** + * Notification inbox endpoints + */ +export const NOTIFICATION_ENDPOINTS = { + GET_ALL: `${NOTIFICATION_API_BASE}/odata/v1/NotificationEntry`, +} as const; diff --git a/tests/integration/config/test-config.ts b/tests/integration/config/test-config.ts index 646bcc5ad..560eab90d 100644 --- a/tests/integration/config/test-config.ts +++ b/tests/integration/config/test-config.ts @@ -8,6 +8,7 @@ export interface IntegrationConfig { baseUrl: string; orgName: string; tenantName: string; + tenantId?: string; secret: string; timeout: number; skipCleanup: boolean; @@ -63,6 +64,7 @@ function validateConfig(rawConfig: Record): IntegrationConfig { baseUrl: rawConfig.baseUrl as string, orgName: rawConfig.orgName as string, tenantName: rawConfig.tenantName as string, + tenantId: typeof rawConfig.tenantId === 'string' ? rawConfig.tenantId : undefined, secret: rawConfig.secret as string, timeout: typeof rawConfig.timeout === 'number' && rawConfig.timeout > 0 ? rawConfig.timeout : 30000, skipCleanup: typeof rawConfig.skipCleanup === 'boolean' ? rawConfig.skipCleanup : false, @@ -100,6 +102,7 @@ export function loadIntegrationConfig(): IntegrationConfig { baseUrl: process.env.UIPATH_BASE_URL, orgName: process.env.UIPATH_ORG_NAME, tenantName: process.env.UIPATH_TENANT_NAME, + tenantId: process.env.UIPATH_TENANT_ID_DEV || undefined, secret: process.env.UIPATH_SECRET, timeout: process.env.INTEGRATION_TEST_TIMEOUT ? parseInt(process.env.INTEGRATION_TEST_TIMEOUT, 10) diff --git a/tests/integration/config/unified-setup.ts b/tests/integration/config/unified-setup.ts index 1b6eed731..118017526 100644 --- a/tests/integration/config/unified-setup.ts +++ b/tests/integration/config/unified-setup.ts @@ -16,6 +16,7 @@ import { AgentMemory } from '../../../src/services/agents/memory'; import { AgentTraces } from '../../../src/services/observability/traces/agent'; import { Traces } from '../../../src/services/observability/traces'; import { Governance } from '../../../src/services/governance'; +import { Notifications } from '../../../src/services/notification'; import { loadIntegrationConfig, IntegrationConfig } from './test-config'; import { UiPath as LegacyUiPath } from '../../../src/uipath'; import { afterAll, beforeAll } from 'vitest'; @@ -56,6 +57,7 @@ export interface TestServices { traces?: Traces; agents?: Agents; governance?: Governance; + notifications?: Notifications; } /** @@ -141,6 +143,7 @@ function createV1Services(config: IntegrationConfig): TestServices { traces: new Traces(sdk), agents: new Agents(sdk), governance: new Governance(sdk), + notifications: new Notifications(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..3c4e7b036 --- /dev/null +++ b/tests/integration/shared/notification/notifications.integration.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { getServices, getTestConfig, setupUnifiedTests, InitMode } from '../../config/unified-setup'; +import { Notifications } from '../../../../src/services/notification'; +import { NotificationGetResponse } from '../../../../src/models/notification'; + +// New modular service — v1 init only. +const modes: InitMode[] = ['v1']; + +// skip: the notification API requires OAuth and the current integration test +// framework only authenticates with a PAT token. Re-enable by removing `.skip` +// once OAuth support is wired into the integration test harness. +describe.skip.each(modes)('Notifications - Integration Tests [%s]', (mode) => { + setupUnifiedTests(mode); + + let notifications!: Notifications; + let tenantId!: string; + + beforeAll(() => { + const service = getServices().notifications; + if (!service) { + throw new Error('Notifications service is not registered for this init mode'); + } + notifications = service; + + const configuredTenantId = getTestConfig().tenantId; + if (!configuredTenantId) { + throw new Error( + 'UIPATH_TENANT_ID_DEV is not configured. Set it to the acting tenant GUID ' + + 'so the notification inbox can be queried.', + ); + } + tenantId = configuredTenantId; + }); + + const ORDER_BY = 'publishedOn desc'; + + describe('getAll', () => { + it('should retrieve notifications with pagination options as a PaginatedResponse', async () => { + const result = await notifications.getAll(tenantId, { pageSize: 5, orderby: ORDER_BY }); + + expect(result).toBeDefined(); + expect(Array.isArray(result.items)).toBe(true); + expect(result.items.length).toBeLessThanOrEqual(5); + expect(result.currentPage).toBe(1); + // OFFSET pagination supports jumping to an arbitrary page. + expect(result.supportsPageJump).toBe(true); + expect(typeof result.hasNextPage).toBe('boolean'); + }); + + it('should filter using the SDK field name `hasRead` (rewritten to the API `isRead`)', async () => { + const result = await notifications.getAll(tenantId, { + filter: 'hasRead eq false', + pageSize: 5, + }); + + expect(result).toBeDefined(); + expect(Array.isArray(result.items)).toBe(true); + // Every returned entry must respect the filter. + for (const item of result.items) { + expect(item.hasRead).toBe(false); + } + }); + + it('should order by publishedOn descending', async () => { + const result = await notifications.getAll(tenantId, { + orderby: 'publishedOn desc', + pageSize: 10, + }); + + expect(result).toBeDefined(); + expect(Array.isArray(result.items)).toBe(true); + for (let i = 1; i < result.items.length; i++) { + expect(result.items[i - 1].publishedOn).toBeGreaterThanOrEqual(result.items[i].publishedOn); + } + }); + + it('should round-trip a cursor to fetch the next page', async () => { + const page1 = await notifications.getAll(tenantId, { pageSize: 1, orderby: ORDER_BY }); + + if (!page1.hasNextPage || !page1.nextCursor) { + throw new Error( + 'Test tenant has fewer than 2 notifications; cursor round-trip cannot be verified. ' + + 'Populate the inbox with test data.', + ); + } + + const page2 = await notifications.getAll(tenantId, { cursor: page1.nextCursor, orderby: ORDER_BY }); + expect(page2).toBeDefined(); + expect(Array.isArray(page2.items)).toBe(true); + expect(page2.currentPage).toBe(2); + }); + + it('should strip internal fields and apply the isRead → hasRead rename', async () => { + const result = await notifications.getAll(tenantId, { pageSize: 1, orderby: ORDER_BY }); + + if (result.items.length === 0) { + throw new Error( + 'Test tenant has no notifications; transform validation cannot be verified. ' + + 'Populate the inbox with test data.', + ); + } + + const entry: NotificationGetResponse = result.items[0]; + + // (a) Public fields present with correct types. + expect(typeof entry.id).toBe('string'); + expect(typeof entry.hasRead).toBe('boolean'); + expect(typeof entry.publishedOn).toBe('number'); + + // (b) API field `isRead` is renamed away. + expect(entry).not.toHaveProperty('isRead'); + + // (c) Internal/transport-layer fields are stripped. + expect(entry).not.toHaveProperty('entityOrgName'); + expect(entry).not.toHaveProperty('entityTenantName'); + expect(entry).not.toHaveProperty('serviceRegistryName'); + expect(entry).not.toHaveProperty('messageTemplateKey'); + expect(entry).not.toHaveProperty('messageVersion'); + expect(entry).not.toHaveProperty('publicationId'); + expect(entry).not.toHaveProperty('correlationId'); + expect(entry).not.toHaveProperty('partitionKey'); + }); + }); +}); diff --git a/tests/unit/services/notification/notifications.test.ts b/tests/unit/services/notification/notifications.test.ts new file mode 100644 index 000000000..5cc1e60ae --- /dev/null +++ b/tests/unit/services/notification/notifications.test.ts @@ -0,0 +1,234 @@ +// ===== IMPORTS ===== +import { describe, it, expect, beforeAll, 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 { TENANT_ID } from '../../../../src/utils/constants/headers'; +import { NOTIFICATION_ENDPOINTS } from '../../../../src/utils/constants/endpoints'; +import { createHeaders } from '../../../../src/utils/http/headers'; +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); + +// 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; + 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 and tenant header', async () => { + const items = [createBasicNotificationEntry()]; + vi.mocked(PaginationHelpers.getAll).mockResolvedValue({ items, totalCount: 1 }); + + const result = await notificationService.getAll(NOTIFICATION_TEST_CONSTANTS.TENANT_ID); + + 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), + headers: TENANT_HEADER, + transformFn: stripInternalNotificationFields, + pagination: expect.objectContaining({ + itemsField: 'value', + totalCountField: '@odata.count', + paginationParams: expect.objectContaining({ + pageSizeParam: '$top', + offsetParam: '$skip', + countParam: '$count', + }), + }), + }), + undefined + ); + }); + + it('rewrites renamed SDK field names in OData options to API names before delegating', async () => { + vi.mocked(PaginationHelpers.getAll).mockResolvedValue({ items: [], totalCount: 0 }); + + // Caller uses the SDK field name `hasRead` in the filter... + await notificationService.getAll(NOTIFICATION_TEST_CONSTANTS.TENANT_ID, { + filter: 'hasRead eq false', + orderby: 'publishedOn desc', + pageSize: 20, + }); + + // ...and the service rewrites it to the API field name `isRead`. + 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(NOTIFICATION_TEST_CONSTANTS.TENANT_ID) + ).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); + + // API `isRead` is renamed to `hasRead` in the SDK response + expect(stripped.hasRead).toBe(false); + expect(stripped).not.toHaveProperty('isRead'); + }); + }); +}); + +/** + * Routing guard for the Notification service. + * + * The notification API routes at the ORGANIZATION level — its URLs must NOT + * include the tenant segment that `ApiClient` injects (`{org}/{tenant}/{path}`) + * for every other service. Routing relies on `NOTIFICATION_BASE`'s `../` prefix + * collapsing the tenant segment via `new URL()` normalization. + * + * These tests exercise the REAL `ApiClient` with the REAL endpoint constant and + * pin the resolved URL, so a future change to `ApiClient`'s URL construction (or + * to `NOTIFICATION_BASE`) that silently breaks org-level routing fails here. + * + * NOTE: the suite above mocks `api-client`, so the real implementation is pulled + * in via `vi.importActual` to exercise genuine URL construction. + */ +describe('Notification org-level URL routing', () => { + let RealApiClient: typeof ApiClient; + + const mockTokenManager = { + getValidToken: vi.fn().mockResolvedValue(TEST_CONSTANTS.DEFAULT_ACCESS_TOKEN), + }; + + const mockConfig = { + baseUrl: TEST_CONSTANTS.BASE_URL, + orgName: TEST_CONSTANTS.ORGANIZATION_ID, + tenantName: TEST_CONSTANTS.TENANT_ID, + }; + + const mockExecutionContext = {}; + + let capturedUrl = ''; + let capturedHeaders: Record = {}; + + beforeAll(async () => { + const actual = await vi.importActual( + '../../../../src/core/http/api-client' + ); + RealApiClient = actual.ApiClient; + }); + + beforeEach(() => { + capturedUrl = ''; + capturedHeaders = {}; + global.fetch = vi.fn().mockImplementation((url: string, options: { headers: Record }) => { + capturedUrl = url; + capturedHeaders = { ...options.headers }; + return Promise.resolve({ + ok: true, + status: 200, + text: () => Promise.resolve(JSON.stringify({ value: [], '@odata.count': 0 })), + }); + }); + }); + + function createClient() { + return new RealApiClient( + mockConfig as any, + mockExecutionContext as any, + mockTokenManager as any, + ); + } + + it('drops the tenant segment for the notification endpoint (org-level routing)', async () => { + const client = createClient(); + + await client.get(NOTIFICATION_ENDPOINTS.GET_ALL); + + const expectedUrl = + `${TEST_CONSTANTS.BASE_URL}/${TEST_CONSTANTS.ORGANIZATION_ID}` + + '/notificationservice_/notificationserviceapi/odata/v1/NotificationEntry'; + + // Exact resolved URL: org segment present, tenant segment collapsed away. + expect(capturedUrl).toBe(expectedUrl); + // The tenant segment ApiClient injects must NOT survive in the path... + expect(capturedUrl).not.toContain(`/${TEST_CONSTANTS.TENANT_ID}/`); + // ...and the `..` must be resolved, never leaked into the final URL. + expect(capturedUrl).not.toContain('..'); + // The org segment is still routed correctly. + expect(capturedUrl).toContain(`/${TEST_CONSTANTS.ORGANIZATION_ID}/`); + }); + + it('forwards the acting tenant via the X-UIPATH-Internal-TenantId header, not the URL', async () => { + const client = createClient(); + + await client.get(NOTIFICATION_ENDPOINTS.GET_ALL, { + headers: createHeaders({ [TENANT_ID]: NOTIFICATION_TEST_CONSTANTS.TENANT_ID }), + }); + + // The tenant GUID identifies the acting tenant via the header... + expect(capturedHeaders[TENANT_ID]).toBe(NOTIFICATION_TEST_CONSTANTS.TENANT_ID); + // ...and must never appear in the org-level URL path. + expect(capturedUrl).not.toContain(NOTIFICATION_TEST_CONSTANTS.TENANT_ID); + }); +}); diff --git a/tests/utils/constants/index.ts b/tests/utils/constants/index.ts index 6f88aebe8..b0f038b53 100644 --- a/tests/utils/constants/index.ts +++ b/tests/utils/constants/index.ts @@ -19,3 +19,4 @@ export * from './memory'; export * from './traces'; export * from './agents'; 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..aee905759 --- /dev/null +++ b/tests/utils/constants/notification.ts @@ -0,0 +1,30 @@ +/** + * Notification test constants. + */ + +export const NOTIFICATION_TEST_CONSTANTS = { + // Tenant GUID — passed as the first arg to every Notifications 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', + + // Publisher / topic IDs used inside notification-entry fixtures + PUBLISHER_ID: '44444444-4444-4444-4444-444444444444', + PUBLISHER_NAME: 'Orchestrator', + TOPIC_ID: '55555555-5555-4555-8555-555555555555', + TOPIC_NAME: 'Process.JobExecution.Faulted', + TOPIC_KEY_NAME: 'Process.JobExecution.Faulted', + + // User identifier + 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, +} as const; diff --git a/tests/utils/mocks/index.ts b/tests/utils/mocks/index.ts index 49e54fd4d..9cdfa44fb 100644 --- a/tests/utils/mocks/index.ts +++ b/tests/utils/mocks/index.ts @@ -22,6 +22,7 @@ export * from './attachments'; export * from './memory'; 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..b1c6f4425 --- /dev/null +++ b/tests/utils/mocks/notification.ts @@ -0,0 +1,47 @@ +/** + * Notification mock factories. + * + * Shapes mirror the real API responses captured live during onboarding (NOT the + * Swagger spec, which omits some nullable behaviour). + */ + +import { + NotificationCategory, + NotificationPriority, +} from '../../../src/models/notification'; +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, +});