diff --git a/src/core/http/api-client.ts b/src/core/http/api-client.ts index 0ea525d18..0850f294a 100644 --- a/src/core/http/api-client.ts +++ b/src/core/http/api-client.ts @@ -81,7 +81,13 @@ export class ApiClient { const searchParams = new URLSearchParams(); if (options.params) { Object.entries(options.params).forEach(([key, value]: [string, any]) => { - searchParams.append(key, value.toString()); + // Array values are serialized as repeated params (key=a&key=b) rather than a + // single comma-joined value, which APIs expecting a collection reject. + if (Array.isArray(value)) { + value.forEach((item) => searchParams.append(key, String(item))); + } else { + searchParams.append(key, String(value)); + } }); } const fullUrl = searchParams.toString() ? `${url}?${searchParams.toString()}` : url; diff --git a/src/models/notification/index.ts b/src/models/notification/index.ts index 037da9f1d..5901cbf75 100644 --- a/src/models/notification/index.ts +++ b/src/models/notification/index.ts @@ -1,6 +1,8 @@ /** - * Notification models barrel export. + * 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.types.ts b/src/models/notification/notifications.types.ts index ebdde50d1..ee3e5c9d5 100644 --- a/src/models/notification/notifications.types.ts +++ b/src/models/notification/notifications.types.ts @@ -31,6 +31,23 @@ export enum NotificationCategory { 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. Always available. */ + InApp = 'InApp', + /** Email delivery. */ + Email = 'Email', + /** Slack delivery. */ + Slack = 'Slack', + /** Microsoft Teams delivery. */ + Teams = 'Teams', +} + /** * Notification entry as returned by `GET /odata/v1/NotificationEntry`. * diff --git a/src/models/notification/subscriptions.models.ts b/src/models/notification/subscriptions.models.ts new file mode 100644 index 000000000..62af26bb8 --- /dev/null +++ b/src/models/notification/subscriptions.models.ts @@ -0,0 +1,104 @@ +/** + * Subscription service model — public response shapes and the ServiceModel interface + * that drives generated API documentation. + */ + +import type { + SubscriptionGetAllOptions, + SubscriptionGetPublishersOptions, + SubscriptionGetPublishersResponse, + SubscriptionGetResponse, + SubscriptionGetSupportedChannelsResponse, +} from './subscriptions.types'; + +/** + * 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, which the SDK + * forwards to the subscription API on each call. + */ +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 tenantId - Tenant GUID + * @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'], + * }); + * ``` + * @internal + */ + getAll(tenantId: string, 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. + * + * 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 + * @param options - Optional publisher-name filter + * @returns Publishers and their full topic catalogue (discovery fields only) + * {@link SubscriptionGetPublishersResponse} + * + * @example Basic usage + * ```typescript + * const { publishers } = await subscriptions.getPublishers(''); + * ``` + * + * @example Filter to a single publisher + * ```typescript + * const { publishers } = await subscriptions.getPublishers('', { name: 'Orchestrator' }); + * ``` + * @internal + */ + getPublishers(tenantId: string, 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 — disabled channels will be rejected by the server. + * + * Note: `InApp` is always available and is not included in the response. + * + * @param tenantId - Tenant GUID + * @returns Supported channels with enabled status + * {@link SubscriptionGetSupportedChannelsResponse} + * + * @example + * ```typescript + * import { NotificationMode } from '@uipath/uipath-typescript/notifications'; + * + * const { channels } = await subscriptions.getSupportedChannels(''); + * const slack = channels.find(c => c.name === NotificationMode.Slack); + * if (slack?.isEnabled) { + * // safe to subscribe to Slack + * } + * ``` + * @internal + */ + getSupportedChannels(tenantId: string): Promise; +} diff --git a/src/models/notification/subscriptions.types.ts b/src/models/notification/subscriptions.types.ts new file mode 100644 index 000000000..6bee7f8c1 --- /dev/null +++ b/src/models/notification/subscriptions.types.ts @@ -0,0 +1,241 @@ +/** + * 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; +} + +/** + * Base topic shape — identity/discovery fields only, with no subscription state. + */ +export interface SubscriptionTopicBase { + /** 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; + /** Topic group name. */ + group: string | null; +} + +/** + * A topic that the user can subscribe to, with full subscription state — as returned by `getAll()`. + */ +export interface SubscriptionTopic extends SubscriptionTopicBase { + /** Severity category. */ + category: NotificationCategory; + /** Parent topic group name. Often `null`. */ + parentGroup: string | null; + /** Whether the user is currently subscribed to this topic. */ + isSubscribed: boolean; + /** Whether the topic is mandatory — cannot be unsubscribed. */ + 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. */ + modes: SubscriptionMode[]; +} + +/** + * An entity reference used in publisher / topic-group subscription requests. + */ +export interface SubscriptionEntity { + /** Entity GUID. */ + id: string; + /** Entity name. */ + name: string | null; + /** Entity type. */ + type: string | null; + /** Parent name (e.g. folder name for a sub-folder). */ + parentName: string | null; + /** 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 | null; + /** Entities belonging to this group. */ + entities: SubscriptionEntity[] | null; +} + +/** + * Entity-sync operation an event maps to. + */ +export enum EntitySyncOperation { + Add = 'Add', + Delete = 'Delete', +} + +/** + * Event mapping that triggers entity synchronization for an entity type. + */ +export interface EntitySyncEvent { + /** Source event name. */ + eventName: string | null; + /** Sync operation the event maps to. */ + operation: EntitySyncOperation; +} + +/** + * Entity-type metadata declared by a publisher (e.g. folder entity registration). + */ +export interface SubscriptionEntityType { + /** Entity type name. */ + type: string | null; + /** URL template for resolving entity links. */ + urlTemplate: string | null; + /** Property used to project the entity in publications. */ + projectionProperty: string | null; + /** Publication payload property carrying the entity reference. */ + publicationPayloadProperty: string | null; + /** Request type used for entity sync. */ + requestType: string | null; + /** Payload template for entity sync requests. */ + payload: string | null; + /** Events that trigger entity synchronization. */ + entitySyncEvents: EntitySyncEvent[] | null; +} + +/** + * Entity types supported by a topic group. + */ +export interface TopicGroupEntityType { + /** Topic group name. */ + name: string | null; + /** Entity type names. */ + entityTypes: string[] | null; +} + +/** + * Base publisher shape — identity/discovery fields and the topic catalogue, with no subscription state. + */ +export interface SubscriptionPublisherBase { + /** Publisher GUID. */ + id: string; + /** Stable publisher name (e.g. `Orchestrator`, `Actions`). */ + name: string; + /** Human-readable publisher name. */ + displayName: string | null; + /** Topics published under this publisher. */ + topics: SubscriptionTopicBase[]; +} + +/** + * A publisher with its topics, channels, and subscription state — as returned by `getAll()`. + */ +export interface SubscriptionPublisher extends SubscriptionPublisherBase { + /** Topics published under this publisher, with full per-topic subscription state. */ + topics: SubscriptionTopic[]; + /** 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[]; + /** Entities (e.g. folders) the user has entity-level subscriptions for. */ + entities: SubscriptionEntity[] | null; + /** Entity types the publisher exposes. */ + entityTypes: SubscriptionEntityType[] | null; + /** Topic-group entity sets the user has entity-level subscriptions for. */ + topicGroupEntities: TopicGroupEntity[]; + /** Topic-group entity types. */ + topicGroupEntityTypes: TopicGroupEntityType[] | null; +} + +/** + * 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; +} + +/** + * 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; +} + +/** + * Response from `getAll()` — publishers with their topics, channels, and full subscription state. + */ +export interface SubscriptionGetResponse { + /** Publishers with their topics and subscription state. */ + publishers: SubscriptionPublisher[]; +} + +/** + * Response from `getPublishers()` — the publisher/topic discovery catalogue. + * + * Publishers and topics carry only identity/discovery fields (no subscription state); + * use `getAll()` to inspect subscription state. + */ +export interface SubscriptionGetPublishersResponse { + /** Publishers with their topic catalogue. */ + publishers: SubscriptionPublisherBase[]; +} + +/** + * Response from `getSupportedChannels()`. + */ +export interface SubscriptionGetSupportedChannelsResponse { + /** Notification channels supported in the current tenant. `InApp` is not listed — it is always available. */ + channels: SupportedChannel[]; +} diff --git a/src/services/notification/index.ts b/src/services/notification/index.ts index aa7e98761..f45681777 100644 --- a/src/services/notification/index.ts +++ b/src/services/notification/index.ts @@ -3,27 +3,31 @@ * * 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) + * - `Notifications` — list / mark-read / delete operations on the user's inbox + * - `Subscriptions` — read the user's notification preferences per publisher and topic * * 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'; + * 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: 'hasRead 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/subscriptions.ts b/src/services/notification/subscriptions.ts new file mode 100644 index 000000000..fe889d58f --- /dev/null +++ b/src/services/notification/subscriptions.ts @@ -0,0 +1,66 @@ +/** + * SubscriptionService — manages the current user's notification preferences. + */ + +import { track } from '../../core/telemetry'; +import { BaseService } from '../base'; + +import type { + SubscriptionGetAllOptions, + SubscriptionGetPublishersOptions, + SubscriptionGetPublishersResponse, + SubscriptionGetResponse, + SubscriptionGetSupportedChannelsResponse, +} from '../../models/notification/subscriptions.types'; +import type { + SubscriptionServiceModel, +} 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. + * + * 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, which the + * SDK forwards to the subscription API on each call. + */ +export class SubscriptionService extends BaseService implements SubscriptionServiceModel { + @track('Subscriptions.GetAll') + async getAll(tenantId: string, options?: SubscriptionGetAllOptions): Promise { + const response = await this.get( + SUBSCRIPTION_ENDPOINTS.GET_ALL, + { + headers: createHeaders({ [TENANT_ID]: tenantId }), + ...(options?.publishers?.length ? { params: { Publishers: options.publishers } } : {}), + } + ); + return response.data; + } + + @track('Subscriptions.GetPublishers') + async getPublishers(tenantId: string, options?: SubscriptionGetPublishersOptions): Promise { + const response = await this.get( + SUBSCRIPTION_ENDPOINTS.GET_PUBLISHERS, + { + headers: createHeaders({ [TENANT_ID]: tenantId }), + ...(options?.name ? { params: { PublisherName: options.name } } : {}), + } + ); + return response.data; + } + + @track('Subscriptions.GetSupportedChannels') + async getSupportedChannels(tenantId: string): Promise { + const response = await this.get( + SUBSCRIPTION_ENDPOINTS.GET_SUPPORTED_CHANNELS, + { 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 211bee2c6..f70c364bc 100644 --- a/src/utils/constants/endpoints/notification.ts +++ b/src/utils/constants/endpoints/notification.ts @@ -1,7 +1,9 @@ /** * Notification Service Endpoints * - * Inbox endpoints under the `notificationservice_/notificationserviceapi` prefix. + * 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}. */ @@ -9,6 +11,7 @@ import { NOTIFICATION_BASE } from './base'; const NOTIFICATION_API_BASE = `${NOTIFICATION_BASE}/notificationserviceapi`; +const SUBSCRIPTION_API_BASE = `${NOTIFICATION_BASE}/usersubscriptionservice`; /** * Notification inbox endpoints @@ -18,3 +21,12 @@ export const NOTIFICATION_ENDPOINTS = { 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`, + GET_SUPPORTED_CHANNELS: `${SUBSCRIPTION_API_BASE}/api/v1/UserSubscription/GetSupportedChannelStatus`, +} as const; diff --git a/tests/integration/config/unified-setup.ts b/tests/integration/config/unified-setup.ts index a33c45385..211486f56 100644 --- a/tests/integration/config/unified-setup.ts +++ b/tests/integration/config/unified-setup.ts @@ -21,7 +21,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 { Notifications, Subscriptions } from '../../../src/services/notification'; import { loadIntegrationConfig, IntegrationConfig } from './test-config'; import { UiPath as LegacyUiPath } from '../../../src/uipath'; import { afterAll, beforeAll } from 'vitest'; @@ -65,6 +65,7 @@ export interface TestServices { agents?: Agents; governance?: Governance; notifications?: Notifications; + subscriptions?: Subscriptions; } /** @@ -155,6 +156,7 @@ function createV1Services(config: IntegrationConfig): TestServices { agents: new Agents(sdk), governance: new Governance(sdk), notifications: new Notifications(sdk), + subscriptions: new Subscriptions(sdk), }; } 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..e8ec8a95e --- /dev/null +++ b/tests/integration/shared/notification/subscriptions.integration.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { getServices, getTestConfig, 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']; + +// skip: the subscription 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)('Subscriptions - Integration Tests [%s]', (mode) => { + setupUnifiedTests(mode); + + let subscriptions!: Subscriptions; + let tenantId!: string; + let allPublishers!: SubscriptionPublisher[]; + 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 configuredTenantId = getTestConfig().tenantId; + if (!configuredTenantId) { + throw new Error( + 'UIPATH_TENANT_ID_DEV is not configured. Set it to the acting tenant GUID ' + + 'so subscription preferences can be queried.', + ); + } + tenantId = configuredTenantId; + + const { publishers } = await subscriptions.getAll(tenantId); + if (publishers.length === 0) { + throw new Error('No publishers visible to the test user — cannot run subscription tests.'); + } + allPublishers = publishers; + 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 a single publisher name', async () => { + const { publishers } = await subscriptions.getAll(tenantId, { publishers: [firstPublisher.name] }); + + expect(publishers.length).toBeGreaterThanOrEqual(1); + for (const p of publishers) { + expect(p.name).toBe(firstPublisher.name); + } + }); + + it('should support filtering by multiple publisher names', async () => { + if (allPublishers.length < 2) { + throw new Error('At least 2 visible publishers are required to test multi-publisher filtering.'); + } + // Verifies the array query param (Publishers) is serialized and forwarded to the API + // correctly — getAll() is the first SDK method to pass an array as a query param. + const requestedNames = allPublishers.slice(0, 2).map(p => p.name); + + const { publishers } = await subscriptions.getAll(tenantId, { publishers: requestedNames }); + + expect(publishers.length).toBe(requestedNames.length); + expect(publishers.map(p => p.name).sort()).toEqual([...requestedNames].sort()); + }); + }); + + describe('getPublishers', () => { + it('should list available publishers with discovery-only fields', async () => { + const { publishers } = await subscriptions.getPublishers(tenantId); + + 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(tenantId, { 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(tenantId); + + 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'); + } + }); + }); +}); diff --git a/tests/unit/core/http/api-client.test.ts b/tests/unit/core/http/api-client.test.ts index 9512a498a..5a37cd73b 100644 --- a/tests/unit/core/http/api-client.test.ts +++ b/tests/unit/core/http/api-client.test.ts @@ -18,10 +18,13 @@ const mockConfig = { const mockExecutionContext = {}; let capturedHeaders: Record = {}; +let capturedUrl = ''; beforeEach(() => { capturedHeaders = {}; - global.fetch = vi.fn().mockImplementation((_url: string, options: any) => { + capturedUrl = ''; + global.fetch = vi.fn().mockImplementation((url: string, options: any) => { + capturedUrl = url; capturedHeaders = { ...options.headers }; return Promise.resolve({ ok: true, @@ -91,6 +94,34 @@ describe('ApiClient traceparent', () => { }); }); +describe('ApiClient query param serialization', () => { + it('serializes an array param as repeated keys, not a comma-joined value', async () => { + const client = createClient(); + await client.get('/test', { params: { Publishers: ['Apps', 'Orchestrator'] } }); + + const query = capturedUrl.split('?')[1] ?? ''; + const publishers = new URLSearchParams(query).getAll('Publishers'); + expect(publishers).toEqual(['Apps', 'Orchestrator']); + expect(capturedUrl).not.toContain('Apps%2COrchestrator'); + }); + + it('serializes a single-element array param as one key', async () => { + const client = createClient(); + await client.get('/test', { params: { Publishers: ['Apps'] } }); + + const query = capturedUrl.split('?')[1] ?? ''; + expect(new URLSearchParams(query).getAll('Publishers')).toEqual(['Apps']); + }); + + it('serializes a scalar param as a single key', async () => { + const client = createClient(); + await client.get('/test', { params: { PublisherName: 'Apps' } }); + + const query = capturedUrl.split('?')[1] ?? ''; + expect(new URLSearchParams(query).getAll('PublisherName')).toEqual(['Apps']); + }); +}); + describe('ApiClient error handling', () => { it('throws ServerError when server returns a non-JSON body on a successful response', async () => { global.fetch = vi.fn().mockResolvedValue({ diff --git a/tests/unit/services/notification/subscriptions.test.ts b/tests/unit/services/notification/subscriptions.test.ts new file mode 100644 index 000000000..050772f79 --- /dev/null +++ b/tests/unit/services/notification/subscriptions.test.ts @@ -0,0 +1,147 @@ +// ===== 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, + createBasicSubscriptionPublisherBase, + 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 { TENANT_ID } from '../../../../src/utils/constants/headers'; +import { NotificationMode } from '../../../../src/models/notification'; + +// ===== 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; + let mockApiClient: ReturnType; + + beforeEach(() => { + const { instance } = createServiceTestDependencies(); + mockApiClient = createMockApiClient(); + vi.mocked(ApiClient).mockImplementation(function () { return mockApiClient as unknown as ApiClient; }); + + subscriptionService = new SubscriptionService(instance); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('getAll', () => { + 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(NOTIFICATION_TEST_CONSTANTS.TENANT_ID); + + expect(mockApiClient.get).toHaveBeenCalledWith(SUBSCRIPTION_ENDPOINTS.GET_ALL, { + headers: TENANT_HEADER, + }); + expect(result).toEqual(mockData); + }); + + 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(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, + 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(NOTIFICATION_TEST_CONSTANTS.TENANT_ID) + ).rejects.toThrow(NOTIFICATION_TEST_CONSTANTS.ERROR_PUBLISHER_NOT_FOUND); + }); + }); + + describe('getPublishers', () => { + it('should GET GetPublishers with only tenant header when no name filter', async () => { + const mockData = { publishers: [createBasicSubscriptionPublisherBase()] }; + mockApiClient.get.mockResolvedValue(mockData); + + const result = await subscriptionService.getPublishers(NOTIFICATION_TEST_CONSTANTS.TENANT_ID); + + expect(mockApiClient.get).toHaveBeenCalledWith(SUBSCRIPTION_ENDPOINTS.GET_PUBLISHERS, { + headers: TENANT_HEADER, + }); + expect(result).toEqual(mockData); + }); + + it('should GET GetPublishers with PublisherName param + tenant header when name supplied', async () => { + const mockData = { publishers: [createBasicSubscriptionPublisherBase()] }; + mockApiClient.get.mockResolvedValue(mockData); + + await subscriptionService.getPublishers(NOTIFICATION_TEST_CONSTANTS.TENANT_ID, { + name: NOTIFICATION_TEST_CONSTANTS.PUBLISHER_NAME, + }); + + expect(mockApiClient.get).toHaveBeenCalledWith( + SUBSCRIPTION_ENDPOINTS.GET_PUBLISHERS, + { 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(NOTIFICATION_TEST_CONSTANTS.TENANT_ID) + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + }); + + describe('getSupportedChannels', () => { + 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(NOTIFICATION_TEST_CONSTANTS.TENANT_ID); + + 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); + expect(result.channels.map(c => c.name)).not.toContain(NotificationMode.InApp); + }); + + it('should propagate errors', async () => { + mockApiClient.get.mockRejectedValue(createMockError(TEST_CONSTANTS.ERROR_MESSAGE)); + + await expect( + subscriptionService.getSupportedChannels(NOTIFICATION_TEST_CONSTANTS.TENANT_ID) + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + }); +}); diff --git a/tests/utils/constants/notification.ts b/tests/utils/constants/notification.ts index 8acea5884..e95463bd3 100644 --- a/tests/utils/constants/notification.ts +++ b/tests/utils/constants/notification.ts @@ -14,9 +14,12 @@ export const NOTIFICATION_TEST_CONSTANTS = { // Publisher / topic IDs used inside notification-entry fixtures PUBLISHER_ID: '44444444-4444-4444-4444-444444444444', PUBLISHER_NAME: 'Orchestrator', + PUBLISHER_DISPLAY_NAME: 'Orchestrator', + PUBLISHER_NAME_ALT: 'Actions', 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 identifier USER_ID: '66666666-6666-4666-8666-666666666666', @@ -31,4 +34,5 @@ export const NOTIFICATION_TEST_CONSTANTS = { // Error messages ERROR_NOTIFICATION_NOT_FOUND: 'Notification not found', + ERROR_PUBLISHER_NOT_FOUND: 'Publisher not found', } as const; diff --git a/tests/utils/mocks/notification.ts b/tests/utils/mocks/notification.ts index b1c6f4425..57a026910 100644 --- a/tests/utils/mocks/notification.ts +++ b/tests/utils/mocks/notification.ts @@ -7,8 +7,17 @@ import { NotificationCategory, + NotificationMode, NotificationPriority, } from '../../../src/models/notification'; +import type { + SubscriptionMode, + SubscriptionPublisher, + SubscriptionPublisherBase, + SubscriptionTopic, + SubscriptionTopicBase, + SupportedChannel, +} from '../../../src/models/notification/subscriptions.types'; import type { RawNotificationEntry } from '../../../src/models/notification/notifications.internal-types'; import { NOTIFICATION_TEST_CONSTANTS } from '../constants/notification'; @@ -45,3 +54,90 @@ export const createBasicNotificationEntry = ( 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 }, + ], + entities: [], + entityTypes: null, + topicGroupEntities: [], + topicGroupEntityTypes: null, + topics: [createBasicSubscriptionTopic()], + ...overrides, +}); + +/** + * Builds a discovery-only topic (as returned by `getPublishers()`) — identity fields only. + */ +export const createBasicSubscriptionTopicBase = ( + overrides?: Partial +): SubscriptionTopicBase => ({ + 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', + ...overrides, +}); + +/** + * Builds a discovery-only publisher (as returned by `getPublishers()`) — identity fields + * plus the topic catalogue, no subscription state. + */ +export const createBasicSubscriptionPublisherBase = ( + overrides?: Partial +): SubscriptionPublisherBase => ({ + id: NOTIFICATION_TEST_CONSTANTS.PUBLISHER_ID, + name: NOTIFICATION_TEST_CONSTANTS.PUBLISHER_NAME, + displayName: NOTIFICATION_TEST_CONSTANTS.PUBLISHER_DISPLAY_NAME, + topics: [createBasicSubscriptionTopicBase()], + ...overrides, +}); + +export const createBasicSupportedChannels = (): SupportedChannel[] => [ + { name: NotificationMode.Email, isEnabled: true }, + { name: NotificationMode.Slack, isEnabled: false }, + { name: NotificationMode.Teams, isEnabled: false }, +];