diff --git a/.claude/settings.json b/.claude/settings.json index 39b143efe..0221eb888 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -6,12 +6,17 @@ "hooks": [ { "type": "command", - "if": "Bash(gh pr create:*)", "command": "jq -r '.tool_input.command' | grep -q 'gh pr create' && echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"additionalContext\":\"STOP: You must run /sdk-review before creating a PR. If sdk-review has NOT been run in this session, abort this PR creation and run /sdk-review first. Only proceed with gh pr create after the review passes.\"}}'", + "if": "Bash(gh pr create:*)", "statusMessage": "Checking pre-PR review requirement..." } ] } ] + }, + "permissions": { + "allow": [ + "Bash(git -c core.editor=true rebase --continue)" + ] } } diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 2775edf5a..cb577ce24 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -76,7 +76,7 @@ jobs: echo "traces_test_trace_id=${{ secrets.UIPATH_TRACES_TEST_TRACE_ID_DEV || secrets.UIPATH_TRACES_TEST_TRACE_ID }}" >> $GITHUB_OUTPUT echo "tasks_test_user_group_id=${{ secrets.UIPATH_TASKS_TEST_USER_GROUP_ID_DEV || secrets.UIPATH_TASKS_TEST_USER_GROUP_ID }}" >> $GITHUB_OUTPUT echo "tasks_test_user_id=${{ secrets.UIPATH_TASKS_TEST_USER_ID_DEV || secrets.UIPATH_TASKS_TEST_USER_ID }}" >> $GITHUB_OUTPUT - echo "identity_test_user_id=${{ secrets.UIPATH_IDENTITY_TEST_USER_ID_DEV || secrets.UIPATH_IDENTITY_TEST_USER_ID }}" >> $GITHUB_OUTPUT + echo "organization_id=${{ secrets.UIPATH_ORGANIZATION_ID_DEV || secrets.UIPATH_ORGANIZATION_ID }}" >> $GITHUB_OUTPUT - name: Create Integration Test Configuration run: | @@ -106,7 +106,7 @@ jobs: TRACES_TEST_TRACE_ID=${{ steps.config.outputs.traces_test_trace_id }} TASKS_TEST_USER_GROUP_ID=${{ steps.config.outputs.tasks_test_user_group_id }} TASKS_TEST_USER_ID=${{ steps.config.outputs.tasks_test_user_id }} - IDENTITY_TEST_USER_ID=${{ steps.config.outputs.identity_test_user_id }} + UIPATH_ORGANIZATION_ID=${{ steps.config.outputs.organization_id }} EOF - name: Run Integration Tests diff --git a/docs/oauth-scopes.md b/docs/oauth-scopes.md index 27c19095c..6b6429411 100644 --- a/docs/oauth-scopes.md +++ b/docs/oauth-scopes.md @@ -280,3 +280,6 @@ User management is authorized by the caller's **organization role** (organizatio | Method | OAuth Scope | |--------|-------------| | `getById()` | None — requires organization administrator role | +| `updateById()` | None — requires organization administrator role | +| `deleteById()` | None — requires organization administrator role | +| `create()` | None — requires organization administrator role | diff --git a/src/models/identity/users.constants.ts b/src/models/identity/users.constants.ts index deb0e0aaf..43062ed73 100644 --- a/src/models/identity/users.constants.ts +++ b/src/models/identity/users.constants.ts @@ -4,12 +4,15 @@ import { UserType, UserCategory } from './users.types'; * User field mappings (API field name → SDK field name). * * Semantic renames only — the API already returns camelCase, so no case - * conversion is involved. + * conversion is involved. `groupIDsToAdd`/`groupIDsToRemove` appear only in + * update requests; `transformRequest()` reverses the map for outbound payloads. */ export const UserMap: { [key: string]: string } = { creationTime: 'createdTime', lastModificationTime: 'lastModifiedTime', groupIDs: 'groupIds', + groupIDsToAdd: 'groupIdsToAdd', + groupIDsToRemove: 'groupIdsToRemove', }; /** diff --git a/src/models/identity/users.internal-types.ts b/src/models/identity/users.internal-types.ts index cbec85f61..d1fdf7407 100644 --- a/src/models/identity/users.internal-types.ts +++ b/src/models/identity/users.internal-types.ts @@ -4,6 +4,8 @@ * NOT exported from the public barrel (`src/models/identity/index.ts`). */ +import type { UserOperationResult } from './users.types'; + /** * Raw user shape as returned by the Identity user management API. * @@ -46,3 +48,14 @@ export interface RawUserEntry { export const INTERNAL_USER_FIELDS = [ 'legacyId', ] as const satisfies ReadonlyArray; + +/** + * Raw response of `POST /api/User/BulkCreate`. + * + * The Swagger spec declares a bare `IdentityResult`, but the live API also + * returns the created users. + */ +export interface RawUserCreateResponse { + result: UserOperationResult; + users: RawUserEntry[]; +} diff --git a/src/models/identity/users.models.ts b/src/models/identity/users.models.ts index 73b1482c3..65170d48f 100644 --- a/src/models/identity/users.models.ts +++ b/src/models/identity/users.models.ts @@ -3,17 +3,37 @@ * interface that drives generated API documentation. */ -import type { RawUserGetResponse } from './users.types'; +import type { + RawUserGetResponse, + UserCreateData, + UserCreateOptions, + UserOperationResult, + UserUpdateOptions, + UserUpdateResponse, +} from './users.types'; /** - * User returned by the Users service. + * User with attached methods */ -export interface UserGetResponse extends RawUserGetResponse {} +export type UserGetResponse = RawUserGetResponse & UserMethods; + +/** + * Response from `create()`. + * + * `result` reflects the request as a whole; `users` contains the created users. + */ +export interface UserCreateResponse { + /** Overall outcome of the create request. */ + result: UserOperationResult; + /** The created users. */ + users: UserGetResponse[]; +} /** * Service model for managing users in a UiPath organization. * - * Provides organization-level user administration. + * Provides organization-level user administration: retrieving, updating and + * deleting users, creating users in bulk, and inviting users by email. * * ### Usage * @@ -31,16 +51,153 @@ export interface UserServiceModel { * Gets a user by ID. * * Returns the full user details including profile fields, group membership, - * activity flags and invitation state. + * activity flags and invitation state, with entity methods attached + * (`update`, `delete`). * * @param userId - User GUID - * @returns Promise resolving to a {@link UserGetResponse} with the user's profile fields, group membership, activity flags and invitation state + * @returns Promise resolving to a {@link UserGetResponse} with the user's profile fields, group membership, activity flags and invitation state, plus bound entity methods * * @example * ```typescript * const user = await users.getById(''); * console.log(`${user.displayName} (${user.email}) — active: ${user.isActive}`); + * + * // Operate on the user directly via bound methods + * await user.update({ displayName: 'New Name' }); * ``` */ getById(userId: string): Promise; + + /** + * Updates a user. Only the provided fields are changed. + * + * @param userId - User GUID + * @param options - Fields to update + * @returns Promise resolving to a {@link UserUpdateResponse} indicating whether the update succeeded and any errors + * + * @example + * ```typescript + * // First, get the user with users.getById() or from users.create() + * const result = await users.updateById('', { displayName: 'New Name' }); + * if (result.succeeded) { + * console.log('User updated'); + * } + * ``` + * + * @example Manage group membership + * ```typescript + * await users.updateById('', { + * groupIdsToAdd: [''], + * groupIdsToRemove: [''], + * }); + * ``` + */ + updateById(userId: string, options: UserUpdateOptions): Promise; + + /** + * Deletes a user. + * + * @param userId - User GUID + * @returns Promise that resolves when the user is deleted + * + * @example + * ```typescript + * await users.deleteById(''); + * ``` + */ + deleteById(userId: string): Promise; + + /** + * Creates users in bulk. + * + * Returns the created users with entity methods attached (`update`, `delete`). + * A single invalid user fails the whole request — check `result.errors` for + * the reason. + * + * @param users - Users to create + * @param organizationId - Organization GUID the users belong to + * @param options - Optional group assignment applied to every created user + * @returns Promise resolving to a {@link UserCreateResponse} with the overall outcome and the created users + * + * @example + * ```typescript + * const response = await users.create( + * [{ userName: 'jdoe', email: 'jdoe@acme.com', name: 'Jane', surname: 'Doe' }], + * '' + * ); + * if (response.result.succeeded) { + * console.log(`Created ${response.users[0].id}`); + * } + * ``` + * + * @example Add every created user to groups + * ```typescript + * const response = await users.create( + * [{ userName: 'jdoe', email: 'jdoe@acme.com' }], + * '', + * { groupIds: [''] } + * ); + * ``` + */ + create( + users: UserCreateData[], + organizationId: string, + options?: UserCreateOptions + ): Promise; +} + +/** + * Methods attached to user objects returned by `getById()` and `create()`. + */ +export interface UserMethods { + /** + * Updates this user. Only the provided fields are changed. + * + * @param options - Fields to update + * @returns Promise resolving to the operation outcome + */ + update(options: UserUpdateOptions): Promise; + + /** + * Deletes this user. + * + * @returns Promise that resolves when the user is deleted + */ + delete(): Promise; +} + +/** + * Creates methods for a user + * + * @param userData - The user data (response from API) + * @param service - The user service instance + * @returns Object containing user methods + */ +function createUserMethods(userData: RawUserGetResponse, service: UserServiceModel): UserMethods { + return { + async update(options: UserUpdateOptions): Promise { + if (!userData.id) throw new Error('User ID is undefined'); + return service.updateById(userData.id, options); + }, + + async delete(): Promise { + if (!userData.id) throw new Error('User ID is undefined'); + return service.deleteById(userData.id); + }, + }; +} + +/** + * Attaches methods to a user object + * + * @param userData - The user data (response from API) + * @param service - The user service instance + * @returns User data with methods attached + */ +export function createUserWithMethods( + userData: RawUserGetResponse, + service: UserServiceModel +): UserGetResponse { + const methods = createUserMethods(userData, service); + return Object.assign({}, userData, methods); } diff --git a/src/models/identity/users.types.ts b/src/models/identity/users.types.ts index 501f9c555..e2694b61f 100644 --- a/src/models/identity/users.types.ts +++ b/src/models/identity/users.types.ts @@ -1,5 +1,5 @@ /** - * Identity user management types — response shapes and enums. + * Identity user management types — response shapes, request payloads and enums. */ /** @@ -72,3 +72,83 @@ export interface RawUserGetResponse { /** Whether the user has accepted their invitation. */ invitationAccepted: boolean; } + +/** + * Error entry from a failed user operation. + */ +export interface UserOperationError { + /** Machine-readable error code (e.g. `InvalidUserName`). */ + code: string; + /** Human-readable error description. */ + description: string; +} + +/** + * Overall outcome of a user operation. + */ +export interface UserOperationResult { + /** Whether the operation succeeded. */ + succeeded: boolean; + /** Errors that caused the operation to fail. Empty on success. */ + errors: UserOperationError[]; +} + +/** + * Response from `updateById()`. + */ +export interface UserUpdateResponse extends UserOperationResult {} + +/** + * Payload for `updateById()`. Only the provided fields are changed. + */ +export interface UserUpdateOptions { + /** New first name. */ + name?: string; + /** New last name. */ + surname?: string; + /** New display name. */ + displayName?: string; + /** New email address. */ + email?: string; + /** Activate (`true`) or deactivate (`false`) the user. */ + isActive?: boolean; + /** New password. */ + password?: string; + /** GUIDs of groups to add the user to. */ + groupIdsToAdd?: string[]; + /** GUIDs of groups to remove the user from. */ + groupIdsToRemove?: string[]; + /** Whether this user bypasses the basic authentication restriction. */ + bypassBasicAuthRestriction?: boolean; + /** Whether the user should be marked as having accepted their invitation. */ + invitationAccepted?: boolean; +} + +/** + * A user to create with `create()`. + */ +export interface UserCreateData { + /** Username — can only contain letters or digits. */ + userName: string; + /** Email address. */ + email?: string; + /** First name. */ + name?: string; + /** Last name. */ + surname?: string; + /** Display name. */ + displayName?: string; + /** Whether the user should be marked as having accepted their invitation. */ + invitationAccepted?: boolean; + /** Whether this user bypasses the basic authentication restriction. */ + bypassBasicAuthRestriction?: boolean; +} + +/** + * Options for `create()`. + */ +export interface UserCreateOptions { + /** GUIDs of groups every created user is added to. */ + groupIds?: string[]; +} + diff --git a/src/services/identity/index.ts b/src/services/identity/index.ts index c6e962b01..5ff724920 100644 --- a/src/services/identity/index.ts +++ b/src/services/identity/index.ts @@ -2,7 +2,7 @@ * Identity Users Service Module * * Provides organization-level user administration via the UiPath Identity API: - * - `Users` — retrieve users in the organization + * - `Users` — retrieve, update and delete users, and create users in bulk * * @example * ```typescript diff --git a/src/services/identity/users.ts b/src/services/identity/users.ts index e932c5a7d..2f4df7ef0 100644 --- a/src/services/identity/users.ts +++ b/src/services/identity/users.ts @@ -5,16 +5,32 @@ import { track } from '../../core/telemetry'; import { BaseService } from '../base'; -import type { RawUserGetResponse } from '../../models/identity/users.types'; -import type { UserGetResponse, UserServiceModel } from '../../models/identity/users.models'; +import type { + RawUserGetResponse, + UserCreateData, + UserCreateOptions, + UserUpdateOptions, + UserUpdateResponse, +} from '../../models/identity/users.types'; +import { + createUserWithMethods, + type UserCreateResponse, + type UserGetResponse, + type UserServiceModel, +} from '../../models/identity/users.models'; import { INTERNAL_USER_FIELDS, + type RawUserCreateResponse, type RawUserEntry, } from '../../models/identity/users.internal-types'; -import { UserCategoryMap, UserMap, UserTypeMap } from '../../models/identity/users.constants'; +import { + UserCategoryMap, + UserMap, + UserTypeMap, +} from '../../models/identity/users.constants'; import { IDENTITY_USER_ENDPOINTS } from '../../utils/constants/endpoints'; -import { applyDataTransforms, transformData } from '../../utils/transform'; +import { applyDataTransforms, transformData, transformRequest } from '../../utils/transform'; /** * Service for managing users in a UiPath organization. @@ -26,7 +42,40 @@ export class UserService extends BaseService implements UserServiceModel { @track('Users.GetById') async getById(userId: string): Promise { const response = await this.get(IDENTITY_USER_ENDPOINTS.BY_ID(userId)); - return this.transformUser(response.data); + return createUserWithMethods(this.transformUser(response.data), this); + } + + @track('Users.UpdateById') + async updateById(userId: string, options: UserUpdateOptions): Promise { + const response = await this.put( + IDENTITY_USER_ENDPOINTS.BY_ID(userId), + transformRequest(options, UserMap) + ); + return response.data; + } + + @track('Users.DeleteById') + async deleteById(userId: string): Promise { + await this.delete(IDENTITY_USER_ENDPOINTS.BY_ID(userId)); + } + + @track('Users.Create') + async create( + users: UserCreateData[], + organizationId: string, + options?: UserCreateOptions + ): Promise { + const response = await this.post(IDENTITY_USER_ENDPOINTS.BULK_CREATE, { + users, + partitionGlobalId: organizationId, + groupIDs: options?.groupIds, + }); + return { + result: response.data.result, + users: response.data.users.map((user) => + createUserWithMethods(this.transformUser(user), this) + ), + }; } /** diff --git a/src/utils/constants/endpoints/identity.ts b/src/utils/constants/endpoints/identity.ts index 99bfc78fc..232a68e95 100644 --- a/src/utils/constants/endpoints/identity.ts +++ b/src/utils/constants/endpoints/identity.ts @@ -21,5 +21,7 @@ const USER_API_BASE = `${IDENTITY_ORG_BASE}/api/User`; * URLs route at the **organization** level (no tenant segment); see {@link IDENTITY_ORG_BASE}. */ export const IDENTITY_USER_ENDPOINTS = { + /** GET (retrieve), PUT (update) and DELETE share this URL — the HTTP method is chosen at the call site. */ BY_ID: (userId: string) => `${USER_API_BASE}/${userId}`, + BULK_CREATE: `${USER_API_BASE}/BulkCreate`, } as const; diff --git a/tests/.env.integration.example b/tests/.env.integration.example index 1c78a80fe..2f61e80f4 100644 --- a/tests/.env.integration.example +++ b/tests/.env.integration.example @@ -85,6 +85,6 @@ TASKS_TEST_USER_GROUP_ID= # The user must have task permissions in the folder identified by INTEGRATION_TEST_FOLDER_ID. TASKS_TEST_USER_ID= -# GUID of a pre-existing user in the test organization (UiPath Cloud > Admin > -# Accounts & Groups). Required by the Identity Users getById tests. -IDENTITY_TEST_USER_ID= +# Organization GUID (from UiPath Cloud > Admin > Organization Settings) +# Required by the Identity Users tests (user create operations) +UIPATH_ORGANIZATION_ID=3aa10965-a82d-4d9e-8366-0eff8e87bf7a diff --git a/tests/integration/config/test-config.ts b/tests/integration/config/test-config.ts index ee211b219..576b0bf13 100644 --- a/tests/integration/config/test-config.ts +++ b/tests/integration/config/test-config.ts @@ -10,10 +10,10 @@ export interface IntegrationConfig { tenantName: string; tenantId?: string; /** - * GUID of a pre-existing user in the test organization — required by the - * Identity Users `getById` tests. + * Organization GUID — required by the Identity Users tests (`create()` takes it + * as the partition the users belong to, and invite redirect URLs embed it). */ - identityTestUserId?: string; + organizationId?: string; secret: string; timeout: number; skipCleanup: boolean; @@ -84,7 +84,7 @@ function validateConfig(rawConfig: Record): IntegrationConfig { orgName: rawConfig.orgName as string, tenantName: rawConfig.tenantName as string, tenantId: typeof rawConfig.tenantId === 'string' ? rawConfig.tenantId : undefined, - identityTestUserId: typeof rawConfig.identityTestUserId === 'string' ? rawConfig.identityTestUserId : undefined, + organizationId: typeof rawConfig.organizationId === 'string' ? rawConfig.organizationId : 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, @@ -128,7 +128,7 @@ export function loadIntegrationConfig(): IntegrationConfig { orgName: process.env.UIPATH_ORG_NAME, tenantName: process.env.UIPATH_TENANT_NAME, tenantId: process.env.UIPATH_TENANT_ID_DEV || undefined, - identityTestUserId: process.env.IDENTITY_TEST_USER_ID || undefined, + organizationId: process.env.UIPATH_ORGANIZATION_ID || undefined, secret: process.env.UIPATH_SECRET, timeout: process.env.INTEGRATION_TEST_TIMEOUT ? parseInt(process.env.INTEGRATION_TEST_TIMEOUT, 10) diff --git a/tests/integration/shared/identity/users.integration.test.ts b/tests/integration/shared/identity/users.integration.test.ts index caa54ddfe..4ae5049aa 100644 --- a/tests/integration/shared/identity/users.integration.test.ts +++ b/tests/integration/shared/identity/users.integration.test.ts @@ -1,7 +1,8 @@ -import { describe, it, expect, beforeAll } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { getServices, getTestConfig, setupUnifiedTests, InitMode } from '../../config/unified-setup'; import { Users } from '../../../../src/services/identity'; import { UserCategory, UserType } from '../../../../src/models/identity'; +import { generateRandomString } from '../../utils/helpers'; // New modular service — v1 init only. const modes: InitMode[] = ['v1']; @@ -10,39 +11,85 @@ describe.each(modes)('Identity Users - Integration Tests [%s]', (mode) => { setupUnifiedTests(mode); let users!: Users; - /** GUID of a pre-existing user in the test organization. */ - let testUserId!: string; + let organizationId!: string; + /** GUID of a user created once in beforeAll and shared by the read/update tests. */ + let sharedUserId!: string; + let sharedUserName!: string; + const createdUserIds: string[] = []; - beforeAll(() => { + beforeAll(async () => { const service = getServices().users; if (!service) { throw new Error('Users service is not registered for this init mode'); } users = service; - const configuredUserId = getTestConfig().identityTestUserId; - if (!configuredUserId) { + const configuredOrganizationId = getTestConfig().organizationId; + if (!configuredOrganizationId) { throw new Error( - 'IDENTITY_TEST_USER_ID is not configured. Set it to the GUID of an existing ' + - 'user in the test organization so the user can be retrieved.', + 'UIPATH_ORGANIZATION_ID is not configured. Set it to the organization GUID ' + + 'so users can be created in the organization partition.', ); } - testUserId = configuredUserId; + organizationId = configuredOrganizationId; + + // Shared fixture user for the read/update tests. + sharedUserName = `sdkit${generateRandomString(10)}`; + const response = await users.create([{ userName: sharedUserName }], organizationId); + if (!response.result.succeeded || response.users.length === 0) { + throw new Error( + `Failed to create the shared fixture user: ${JSON.stringify(response.result.errors)}`, + ); + } + sharedUserId = response.users[0].id; + createdUserIds.push(sharedUserId); + }); + + afterAll(async () => { + for (const userId of createdUserIds) { + await users.deleteById(userId); + } + }); + + describe('create', () => { + it('should create a user with profile fields and group membership', async () => { + const userName = `sdkit${generateRandomString(10)}`; + + const response = await users.create( + [{ userName, name: 'Sdk', surname: 'Integration' }], + organizationId, + ); + + expect(response.result.succeeded).toBe(true); + expect(response.result.errors).toEqual([]); + expect(response.users.length).toBe(1); + + const user = response.users[0]; + createdUserIds.push(user.id); + + expect(user.userName).toBe(userName); + expect(user.name).toBe('Sdk'); + expect(user.surname).toBe('Integration'); + expect(user.isActive).toBe(true); + + // Created users carry bound entity methods + expect(typeof user.update).toBe('function'); + expect(typeof user.delete).toBe('function'); + }); }); describe('getById', () => { - it('should retrieve the user by ID', async () => { - const user = await users.getById(testUserId); - - expect(user.id).toBe(testUserId); - expect(typeof user.userName).toBe('string'); - expect(user.userName.length).toBeGreaterThan(0); - expect(typeof user.isActive).toBe('boolean'); - expect(typeof user.invitationAccepted).toBe('boolean'); + it('should retrieve the user by ID with bound methods', async () => { + const user = await users.getById(sharedUserId); + + expect(user.id).toBe(sharedUserId); + expect(user.userName).toBe(sharedUserName); + expect(typeof user.update).toBe('function'); + expect(typeof user.delete).toBe('function'); }); it('should apply the SDK transform pipeline to the live response', async () => { - const user = await users.getById(testUserId); + const user = await users.getById(sharedUserId); // (a) Renamed/typed fields present with values. expect(typeof user.createdTime).toBe('string'); @@ -60,4 +107,44 @@ describe.each(modes)('Identity Users - Integration Tests [%s]', (mode) => { expect(user).not.toHaveProperty('legacyId'); }); }); + + describe('updateById', () => { + it('should update profile fields and report success', async () => { + const displayName = `SDK IT ${generateRandomString(6)}`; + + const result = await users.updateById(sharedUserId, { displayName }); + + expect(result.succeeded).toBe(true); + expect(result.errors).toEqual([]); + + const user = await users.getById(sharedUserId); + expect(user.displayName).toBe(displayName); + }); + + it('should update via the bound method on a retrieved user', async () => { + const user = await users.getById(sharedUserId); + const displayName = `SDK Bound ${generateRandomString(6)}`; + + const result = await user.update({ displayName }); + + expect(result.succeeded).toBe(true); + + const refreshed = await users.getById(sharedUserId); + expect(refreshed.displayName).toBe(displayName); + }); + }); + + describe('deleteById', () => { + it('should delete a user and make subsequent retrieval fail', async () => { + const userName = `sdkit${generateRandomString(10)}`; + const response = await users.create([{ userName }], organizationId); + const userId = response.users[0].id; + createdUserIds.push(userId); + + await users.deleteById(userId); + createdUserIds.splice(createdUserIds.indexOf(userId), 1); + + await expect(users.getById(userId)).rejects.toThrow(); + }); + }); }); diff --git a/tests/unit/models/identity/users.test.ts b/tests/unit/models/identity/users.test.ts new file mode 100644 index 000000000..5128c0fc1 --- /dev/null +++ b/tests/unit/models/identity/users.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + createUserWithMethods, + UserServiceModel, +} from '../../../../src/models/identity/users.models'; +import { createBasicUser, USER_TEST_CONSTANTS } from '../../../utils/mocks'; +import type { UserUpdateOptions } from '../../../../src/models/identity/users.types'; + +// ===== TEST SUITE ===== +describe('User Models', () => { + let mockService: UserServiceModel; + + beforeEach(() => { + mockService = { + getById: vi.fn(), + updateById: vi.fn(), + deleteById: vi.fn(), + create: vi.fn(), + invite: vi.fn(), + }; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('createUserWithMethods', () => { + it('should merge user data with bound methods', () => { + const user = createUserWithMethods(createBasicUser(), mockService); + + expect(user.id).toBe(USER_TEST_CONSTANTS.USER_ID); + expect(user.userName).toBe(USER_TEST_CONSTANTS.USER_NAME); + expect(typeof user.update).toBe('function'); + expect(typeof user.delete).toBe('function'); + }); + }); + + describe('user.update()', () => { + it('should call service.updateById with the bound user ID', async () => { + const user = createUserWithMethods(createBasicUser(), mockService); + const options: UserUpdateOptions = { displayName: USER_TEST_CONSTANTS.DISPLAY_NAME }; + vi.mocked(mockService.updateById).mockResolvedValue({ succeeded: true, errors: [] }); + + const result = await user.update(options); + + expect(mockService.updateById).toHaveBeenCalledWith(USER_TEST_CONSTANTS.USER_ID, options); + expect(result).toEqual({ succeeded: true, errors: [] }); + }); + + it('should throw when the user ID is undefined', async () => { + const user = createUserWithMethods(createBasicUser({ id: undefined as any }), mockService); + + await expect(user.update({ isActive: false })).rejects.toThrow('User ID is undefined'); + expect(mockService.updateById).not.toHaveBeenCalled(); + }); + }); + + describe('user.delete()', () => { + it('should call service.deleteById with the bound user ID', async () => { + const user = createUserWithMethods(createBasicUser(), mockService); + vi.mocked(mockService.deleteById).mockResolvedValue(undefined); + + await expect(user.delete()).resolves.toBeUndefined(); + + expect(mockService.deleteById).toHaveBeenCalledWith(USER_TEST_CONSTANTS.USER_ID); + }); + + it('should throw when the user ID is undefined', async () => { + const user = createUserWithMethods(createBasicUser({ id: undefined as any }), mockService); + + await expect(user.delete()).rejects.toThrow('User ID is undefined'); + expect(mockService.deleteById).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/unit/services/identity/users.test.ts b/tests/unit/services/identity/users.test.ts index dd1f9f0e8..6ffd043f6 100644 --- a/tests/unit/services/identity/users.test.ts +++ b/tests/unit/services/identity/users.test.ts @@ -70,6 +70,15 @@ describe('UserService Unit Tests', () => { expect((result as any).legacyId).toBeUndefined(); }); + it('should attach bound methods to the returned user', async () => { + mockApiClient.get.mockResolvedValue(createBasicRawUserEntry()); + + const result = await userService.getById(USER_TEST_CONSTANTS.USER_ID); + + expect(typeof result.update).toBe('function'); + expect(typeof result.delete).toBe('function'); + }); + it('should propagate errors', async () => { mockApiClient.get.mockRejectedValue(createMockError(USER_TEST_CONSTANTS.ERROR_USER_NOT_FOUND)); @@ -78,4 +87,139 @@ describe('UserService Unit Tests', () => { ); }); }); + + describe('updateById', () => { + it('should PUT the update payload with group id fields renamed to API names', async () => { + mockApiClient.put.mockResolvedValue({ succeeded: true, errors: [] }); + + const result = await userService.updateById(USER_TEST_CONSTANTS.USER_ID, { + displayName: USER_TEST_CONSTANTS.DISPLAY_NAME, + groupIdsToAdd: [USER_TEST_CONSTANTS.GROUP_ID], + groupIdsToRemove: [USER_TEST_CONSTANTS.GROUP_ID_2], + }); + + expect(mockApiClient.put).toHaveBeenCalledWith( + IDENTITY_USER_ENDPOINTS.BY_ID(USER_TEST_CONSTANTS.USER_ID), + { + displayName: USER_TEST_CONSTANTS.DISPLAY_NAME, + groupIDsToAdd: [USER_TEST_CONSTANTS.GROUP_ID], + groupIDsToRemove: [USER_TEST_CONSTANTS.GROUP_ID_2], + }, + expect.anything() + ); + expect(result).toEqual({ succeeded: true, errors: [] }); + }); + + it('should propagate errors', async () => { + mockApiClient.put.mockRejectedValue(createMockError(USER_TEST_CONSTANTS.ERROR_USER_NOT_FOUND)); + + await expect( + userService.updateById(USER_TEST_CONSTANTS.USER_ID, { isActive: false }) + ).rejects.toThrow(USER_TEST_CONSTANTS.ERROR_USER_NOT_FOUND); + }); + }); + + describe('deleteById', () => { + it('should DELETE the user', async () => { + mockApiClient.delete.mockResolvedValue(undefined); + + await expect(userService.deleteById(USER_TEST_CONSTANTS.USER_ID)).resolves.toBeUndefined(); + + expect(mockApiClient.delete).toHaveBeenCalledWith( + IDENTITY_USER_ENDPOINTS.BY_ID(USER_TEST_CONSTANTS.USER_ID), + expect.anything() + ); + }); + + it('should propagate errors', async () => { + mockApiClient.delete.mockRejectedValue(createMockError(USER_TEST_CONSTANTS.ERROR_USER_NOT_FOUND)); + + await expect(userService.deleteById(USER_TEST_CONSTANTS.USER_ID)).rejects.toThrow( + USER_TEST_CONSTANTS.ERROR_USER_NOT_FOUND + ); + }); + }); + + describe('create', () => { + it('should POST users with the organization id as partitionGlobalId and groupIds renamed', async () => { + mockApiClient.post.mockResolvedValue({ + result: { succeeded: true, errors: [] }, + users: [createBasicRawUserEntry()], + }); + + const result = await userService.create( + [{ userName: USER_TEST_CONSTANTS.USER_NAME, email: USER_TEST_CONSTANTS.EMAIL }], + USER_TEST_CONSTANTS.ORGANIZATION_ID, + { groupIds: [USER_TEST_CONSTANTS.GROUP_ID] } + ); + + expect(mockApiClient.post).toHaveBeenCalledWith( + IDENTITY_USER_ENDPOINTS.BULK_CREATE, + { + users: [{ userName: USER_TEST_CONSTANTS.USER_NAME, email: USER_TEST_CONSTANTS.EMAIL }], + partitionGlobalId: USER_TEST_CONSTANTS.ORGANIZATION_ID, + groupIDs: [USER_TEST_CONSTANTS.GROUP_ID], + }, + expect.anything() + ); + expect(result.result.succeeded).toBe(true); + expect(result.users.length).toBe(1); + }); + + it('should transform created users and attach bound methods', async () => { + mockApiClient.post.mockResolvedValue({ + result: { succeeded: true, errors: [] }, + users: [createBasicRawUserEntry()], + }); + + const result = await userService.create( + [{ userName: USER_TEST_CONSTANTS.USER_NAME }], + USER_TEST_CONSTANTS.ORGANIZATION_ID + ); + + const user = result.users[0]; + expect(user.createdTime).toBe(USER_TEST_CONSTANTS.CREATION_TIME); + expect(user.groupIds).toEqual([USER_TEST_CONSTANTS.GROUP_ID]); + expect(user.type).toBe(UserType.User); + expect((user as any).creationTime).toBeUndefined(); + expect((user as any).groupIDs).toBeUndefined(); + expect((user as any).legacyId).toBeUndefined(); + expect(typeof user.update).toBe('function'); + expect(typeof user.delete).toBe('function'); + }); + + it('should omit groupIDs from the payload when no options are provided', async () => { + mockApiClient.post.mockResolvedValue({ + result: { succeeded: true, errors: [] }, + users: [createBasicRawUserEntry()], + }); + + await userService.create( + [{ userName: USER_TEST_CONSTANTS.USER_NAME }], + USER_TEST_CONSTANTS.ORGANIZATION_ID + ); + + expect(mockApiClient.post).toHaveBeenCalledWith( + IDENTITY_USER_ENDPOINTS.BULK_CREATE, + { + users: [{ userName: USER_TEST_CONSTANTS.USER_NAME }], + partitionGlobalId: USER_TEST_CONSTANTS.ORGANIZATION_ID, + groupIDs: undefined, + }, + expect.anything() + ); + }); + + it('should propagate errors', async () => { + mockApiClient.post.mockRejectedValue(createMockError(USER_TEST_CONSTANTS.ERROR_USER_NOT_FOUND)); + + await expect( + userService.create( + [{ userName: USER_TEST_CONSTANTS.USER_NAME }], + USER_TEST_CONSTANTS.ORGANIZATION_ID + ) + ).rejects.toThrow(USER_TEST_CONSTANTS.ERROR_USER_NOT_FOUND); + }); + }); + }); diff --git a/tests/utils/constants/users.ts b/tests/utils/constants/users.ts index ffd52b325..2dde8f27d 100644 --- a/tests/utils/constants/users.ts +++ b/tests/utils/constants/users.ts @@ -6,8 +6,12 @@ export const USER_TEST_CONSTANTS = { // User identifiers USER_ID: '1fb982fc-6078-408e-98ef-180739a17cc5', + // Organization (partition) GUID — required by create() + ORGANIZATION_ID: '3aa10965-a82d-4d9e-8366-0eff8e87bf7a', + // Group GUIDs GROUP_ID: '35551807-06b1-4cda-90a1-2fb84851eee7', + GROUP_ID_2: 'cdc34b5b-77d2-4ae1-9744-209d21ce557d', // Profile fields USER_NAME: 'jdoe', diff --git a/tests/utils/mocks/users.ts b/tests/utils/mocks/users.ts index 114bbb1c5..80758bd67 100644 --- a/tests/utils/mocks/users.ts +++ b/tests/utils/mocks/users.ts @@ -7,8 +7,35 @@ */ import type { RawUserEntry } from '../../../src/models/identity/users.internal-types'; +import type { RawUserGetResponse } from '../../../src/models/identity'; +import { UserCategory, UserType } from '../../../src/models/identity'; import { USER_TEST_CONSTANTS } from '../constants/users'; +/** + * Builds a user in the SDK response shape (transformed, without bound methods). + */ +export const createBasicUser = ( + overrides?: Partial +): RawUserGetResponse => ({ + id: USER_TEST_CONSTANTS.USER_ID, + userName: USER_TEST_CONSTANTS.USER_NAME, + email: USER_TEST_CONSTANTS.EMAIL, + emailConfirmed: true, + name: USER_TEST_CONSTANTS.FIRST_NAME, + surname: USER_TEST_CONSTANTS.LAST_NAME, + displayName: USER_TEST_CONSTANTS.DISPLAY_NAME, + createdTime: USER_TEST_CONSTANTS.CREATION_TIME, + lastModifiedTime: USER_TEST_CONSTANTS.LAST_MODIFICATION_TIME, + lastLoginTime: USER_TEST_CONSTANTS.LAST_LOGIN_TIME, + groupIds: [USER_TEST_CONSTANTS.GROUP_ID], + isActive: true, + bypassBasicAuthRestriction: false, + type: UserType.User, + category: UserCategory.Local, + invitationAccepted: true, + ...overrides, +}); + /** * Builds a raw user entry mirroring a live API response. */