diff --git a/docs/oauth-scopes.md b/docs/oauth-scopes.md index 2f25f66f7..8c8a1f864 100644 --- a/docs/oauth-scopes.md +++ b/docs/oauth-scopes.md @@ -61,6 +61,34 @@ This page lists the specific OAuth scopes required in external app for each SDK | `queryRecordsById()` / `queryRecords()` | `DataFabric.Data.Read` | | `importRecordsById()` / `importRecords()` | `DataFabric.Data.Write` | +## Entities v3 + +Composite-entity API (`@uipath/uipath-typescript/entities-v3`). + +| Method | OAuth Scope | +|--------|-------------| +| `getAll()` | `DataFabric.Schema.Read` | +| `getAllWithChoiceSets()` | `DataFabric.Schema.Read` | +| `getFolderEntities()` | `DataFabric.Schema.Read` | +| `getById()` | `DataFabric.Schema.Read` | +| `getMetadata()` | `DataFabric.Schema.Read` | +| `create()` | `DataFabric.Schema.Write` | +| `deleteById()` | `DataFabric.Schema.Write` | +| `updateMetadata()` | `DataFabric.Schema.Write` | +| `createField()` / `createMemberField()` | `DataFabric.Schema.Write` | +| `updateField()` / `updateMemberField()` | `DataFabric.Schema.Write` | +| `deleteField()` / `deleteMemberField()` / `deleteMemberFieldHard()` | `DataFabric.Schema.Write` | +| `getRecords()` / `getRecord()` / `getRecordByKey()` | `DataFabric.Data.Read` | +| `query()` / `queryWithExpansion()` | `DataFabric.Data.Read` | +| `insert()` / `insertRecords()` / `insertBulk()` / `upsert()` | `DataFabric.Data.Write` | +| `update()` / `updateByKey()` / `updateRecords()` / `updateWhere()` | `DataFabric.Data.Write` | +| `deleteRecord()` / `deleteRecords()` / `deleteRecordsBatch()` | `DataFabric.Data.Write` | +| `queryMember()` / `getMemberRecords()` / `getMemberRecord()` / `getMemberRecordByKey()` | `DataFabric.Data.Read` | +| `deleteMemberRecord()` / `deleteMemberRecords()` | `DataFabric.Data.Write` | +| `downloadAttachment()` | `DataFabric.Data.Read` | +| `uploadAttachment()` / `deleteAttachment()` | `DataFabric.Data.Write` | +| `manageWithAutopilot()` / `manageWithAutopilotStream()` | `DataFabric.Data.Write` | + ## ChoiceSets | Method | OAuth Scope | diff --git a/mkdocs.yml b/mkdocs.yml index 3fdc0c8b8..0248aeba1 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -198,6 +198,7 @@ nav: - Entities: - api/interfaces/entity/index.md - Choice Sets: api/interfaces/ChoiceSetServiceModel.md + - Entities v3: api/interfaces/EntityV3ServiceModel.md - Governance: api/interfaces/GovernanceServiceModel.md - Maestro: - Processes: api/interfaces/MaestroProcessesServiceModel.md diff --git a/package.json b/package.json index 89851a0cb..3e42fffc5 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,16 @@ "default": "./dist/entities/index.cjs" } }, + "./entities-v3": { + "import": { + "types": "./dist/entities-v3/index.d.ts", + "default": "./dist/entities-v3/index.mjs" + }, + "require": { + "types": "./dist/entities-v3/index.d.ts", + "default": "./dist/entities-v3/index.cjs" + } + }, "./tasks": { "import": { "types": "./dist/tasks/index.d.ts", diff --git a/rollup.config.js b/rollup.config.js index 4904bb9d2..921ac4470 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -144,6 +144,11 @@ const serviceEntries = [ input: 'src/services/data-fabric/index.ts', output: 'entities/index' }, + { + name: 'entities-v3', + input: 'src/services/data-fabric/entities-v3/index.ts', + output: 'entities-v3/index' + }, { name: 'tasks', input: 'src/services/action-center/index.ts', diff --git a/src/core/http/api-client.ts b/src/core/http/api-client.ts index 0ea525d18..2dffd498b 100644 --- a/src/core/http/api-client.ts +++ b/src/core/http/api-client.ts @@ -115,6 +115,13 @@ export class ApiClient { return blob as T; } + // Handle stream response type: return the raw body without buffering + // (used for server-sent-event / chunked endpoints). `response.body` is + // null only for empty/204 responses, already handled above. + if (options.responseType === RESPONSE_TYPES.STREAM) { + return response.body as T; + } + // Check if we're expecting XML const acceptHeader = headers['Accept'] || headers['accept']; if (acceptHeader === CONTENT_TYPES.XML) { diff --git a/src/models/data-fabric/entities-v3.models.ts b/src/models/data-fabric/entities-v3.models.ts new file mode 100644 index 000000000..2054490ac --- /dev/null +++ b/src/models/data-fabric/entities-v3.models.ts @@ -0,0 +1,957 @@ +/** + * Model definitions for the Data Fabric Entity **v3** service — composed response + * type, the public {@link EntityV3ServiceModel} interface (source of truth for the + * API docs), the bound-methods interface, and the method-attachment factory. + */ + +import { + EntityV3Metadata, + EntityV3ListResponse, + EntityV3WriteResponse, + EntityV3QueryExpansionResponse, + EntityV3MemberDeleteResponse, + EntityV3BatchResponse, + EntityV3RecordInput, + EntityV3ConditionalUpdateRequest, + EntityV3CreateRequest, + EntityV3CompositeCreateResponse, + EntityV3UpdateMetadataRequest, + EntityV3FieldCreateRequest, + EntityV3FieldUpdateRequest, + EntityV3AutopilotRequest, + EntityV3AutopilotResponse, + EntityV3GetAllOptions, + EntityV3GetAllPagedOptions, + EntityV3ReadOptions, + EntityV3GetRecordOptions, + EntityV3QueryOptions, + EntityV3InsertOptions, + EntityV3BatchOptions, + EntityV3UpdateOptions, + EntityV3DeleteOptions, + EntityV3InsertBulkOptions, + EntityV3UpdateWhereOptions, + EntityV3DeleteBatchOptions, + EntityV3MemberReadOptions, + EntityV3MemberGetRecordOptions, + EntityV3MemberQueryOptions, + EntityV3UploadAttachmentOptions, + EntityV3DownloadAttachmentOptions, + EntityV3DeleteAttachmentOptions, + EntityV3SchemaOptions, +} from './entities-v3.types'; +import { EntityFileType } from './entities.types'; +import { PaginatedResponse, NonPaginatedResponse, HasPaginationOptions } from '../../utils/pagination/types'; + +/** Response from creating an entity: the new entity id (single) or the composite descriptor. */ +export type EntityV3CreateResponse = string | EntityV3CompositeCreateResponse; + +/** + * Service for the UiPath Data Fabric Entity **v3** API. + * + * v3 adds composite-entity support — a logical business entity backed by multiple + * related "member" tables — while remaining a superset of the v1/v2 data surface. + * Reads and queries resolve to {@link EntityV3WriteResponse} records (paginated where + * applicable), and writes return an {@link EntityV3WriteResponse}, so callers use one + * record model for composite and non-composite entities alike. Composite child records + * are keyed by member instance name under `children`. + * + * Data operations are addressed by entity **name**; schema operations are addressed + * by entity **id** (GUID). Composite child records are keyed by member instance name + * under `children`. + * + * ### Usage + * + * Prerequisites: Initialize the SDK first - see [Getting Started](/uipath-typescript/getting-started/#import-initialize) + * + * ```typescript + * import { EntitiesV3 } from '@uipath/uipath-typescript/entities-v3'; + * + * const entitiesV3 = new EntitiesV3(sdk); + * const entities = await entitiesV3.getAll(); + * ``` + */ +export interface EntityV3ServiceModel { + /** + * Lists entities in the tenant (v3). Composite entities appear with + * `isComposite: true`; composite member tables are hidden from this listing. + * + * @param options - Optional {@link EntityV3GetAllOptions} (`entityClass` filter, `folderKey`). `folderKey` is **experimental**. + * @returns Promise resolving to an array of entity metadata {@link EntityV3Metadata} + * @example + * ```typescript + * const entities = await entitiesV3.getAll(); + * + * // Only composite entities + * const composites = await entitiesV3.getAll({ entityClass: "CaseComposite" }); + * ``` + */ + getAll(options?: EntityV3GetAllOptions): Promise; + + /** + * Lists entities together with the choice sets in scope (v3 `all` endpoint), + * using a raw start/limit window. + * + * @param options - Optional {@link EntityV3GetAllPagedOptions} (`start`, `limit`, `folderKey`). `folderKey` is **experimental**. + * @returns Promise resolving to entities and choice sets {@link EntityV3ListResponse} + * @example + * ```typescript + * const { entities, choicesets } = await entitiesV3.getAllWithChoiceSets({ start: 0, limit: 100 }); + * ``` + */ + getAllWithChoiceSets(options?: EntityV3GetAllPagedOptions): Promise; + + /** + * Lists tenant-level and folder-level entities together (v3). + * + * @param options - Optional {@link EntityV3GetAllOptions} (`folderKey`). `folderKey` is **experimental**. + * @returns Promise resolving to an array of entity metadata {@link EntityV3Metadata} + * @example + * ```typescript + * const entities = await entitiesV3.getFolderEntities(); + * ``` + */ + getFolderEntities(options?: EntityV3GetAllOptions): Promise; + + /** + * Gets entity metadata by entity ID, with operation methods attached. + * + * @param entityId - UUID of the entity + * @param options - Optional {@link EntityV3SchemaOptions} (`folderKey`). `folderKey` is **experimental**. + * @returns Promise resolving to entity metadata with operation methods {@link EntityV3GetResponse} + * @example + * ```typescript + * const entity = await entitiesV3.getById(""); + * const records = await entity.getRecords(); + * ``` + */ + getById(entityId: string, options?: EntityV3SchemaOptions): Promise; + + /** + * Gets entity metadata by entity **name**, enriched with composite info when + * applicable, with operation methods attached. Use this to discover a composite's + * member tree via `compositeInfo`. + * + * @param entityName - Name of the entity + * @param options - Optional {@link EntityV3SchemaOptions} (`folderKey`). `folderKey` is **experimental**. + * @returns Promise resolving to entity metadata with operation methods {@link EntityV3GetResponse} + * @example + * ```typescript + * const entity = await entitiesV3.getMetadata("LoanCaseForBank"); + * if (entity.isComposite) { + * console.log(entity.compositeInfo?.members?.map(m => m.entityName)); + * } + * ``` + */ + getMetadata(entityName: string, options?: EntityV3SchemaOptions): Promise; + + /** + * Creates a single or composite entity. Omit `members` for a single entity; + * include a non-empty `members` array to create a composite. + * + * @param request - The entity creation payload {@link EntityV3CreateRequest} + * @param options - Optional {@link EntityV3SchemaOptions} (`folderKey`). `folderKey` is **experimental**. + * @returns Promise resolving to the new entity id (single) or composite descriptor {@link EntityV3CreateResponse} + * @example + * ```typescript + * import { EntityFieldDataType } from '@uipath/uipath-typescript/entities'; + * + * // Single entity + * const id = await entitiesV3.create({ + * displayName: "Users", + * entityDefinition: { + * name: "Users", + * fields: [{ name: "Email", isRequired: true, fieldDataType: { name: EntityFieldDataType.STRING } }], + * }, + * }); + * ``` + */ + create(request: EntityV3CreateRequest, options?: EntityV3SchemaOptions): Promise; + + /** + * Deletes an entity by id. Deleting a composite always cascades to all member tables. + * + * @param entityId - UUID of the entity + * @param options - Optional {@link EntityV3SchemaOptions} (`folderKey`). `folderKey` is **experimental**. + * @returns Promise resolving when the entity is deleted + * @example + * ```typescript + * await entitiesV3.deleteById(""); + * ``` + */ + deleteById(entityId: string, options?: EntityV3SchemaOptions): Promise; + + /** + * Updates entity metadata (display name, description, folder, RBAC/Insights flags). + * + * @param entityId - UUID of the entity + * @param request - Metadata changes to apply {@link EntityV3UpdateMetadataRequest} + * @param options - Optional {@link EntityV3SchemaOptions} (`folderKey`). `folderKey` is **experimental**. + * @returns Promise resolving to `true` on success + * @example + * ```typescript + * await entitiesV3.updateMetadata("", { displayName: "Renamed" }); + * ``` + */ + updateMetadata(entityId: string, request: EntityV3UpdateMetadataRequest, options?: EntityV3SchemaOptions): Promise; + + /** + * Adds a field to an entity. + * + * @param entityId - UUID of the entity + * @param request - Field definition {@link EntityV3FieldCreateRequest} + * @param options - Optional {@link EntityV3SchemaOptions} (`folderKey`). `folderKey` is **experimental**. + * @returns Promise resolving to the new field's UUID + * @example + * ```typescript + * import { EntityFieldDataType } from '@uipath/uipath-typescript/entities'; + * + * const fieldId = await entitiesV3.createField("", { + * fieldDefinition: { name: "Notes", fieldDataType: { name: EntityFieldDataType.STRING } }, + * }); + * ``` + */ + createField(entityId: string, request: EntityV3FieldCreateRequest, options?: EntityV3SchemaOptions): Promise; + + /** + * Updates a field's metadata (display name, required, etc.). The SQL type name is immutable. + * + * @param entityId - UUID of the entity + * @param fieldId - UUID of the field + * @param request - Field metadata changes {@link EntityV3FieldUpdateRequest} + * @param options - Optional {@link EntityV3SchemaOptions} (`folderKey`). `folderKey` is **experimental**. + * @returns Promise resolving to `true` on success + * @example + * ```typescript + * await entitiesV3.updateField("", "", { displayName: "Internal Notes" }); + * ``` + */ + updateField(entityId: string, fieldId: string, request: EntityV3FieldUpdateRequest, options?: EntityV3SchemaOptions): Promise; + + /** + * Soft-deletes a field (preserves the underlying column for recovery). + * + * @param entityId - UUID of the entity + * @param fieldId - UUID of the field + * @param options - Optional {@link EntityV3SchemaOptions} (`folderKey`). `folderKey` is **experimental**. + * @returns Promise resolving to `true` on success + * @example + * ```typescript + * await entitiesV3.deleteField("", ""); + * ``` + */ + deleteField(entityId: string, fieldId: string, options?: EntityV3SchemaOptions): Promise; + + /** + * Reads a page of records for an entity by name. For composite entities the + * response includes nested `children` per record (capped by expansion level). + * + * @param entityName - Name of the entity + * @param options - Optional {@link EntityV3ReadOptions} (`expansionLevel`, pagination, `folderKey`). `folderKey` is **experimental**. + * @returns Promise resolving to an array of records or a paginated response of {@link EntityV3WriteResponse} + * @example + * ```typescript + * const records = await entitiesV3.getRecords("Customer"); + * + * // Composite: include one level of children, first page + * const page = await entitiesV3.getRecords("LoanCaseForBank", { expansionLevel: 1, pageSize: 50 }); + * ``` + */ + getRecords( + entityName: string, + options?: T + ): Promise ? PaginatedResponse : NonPaginatedResponse>; + + /** + * Reads a single record by its system Id (GUID). + * + * @param entityName - Name of the entity + * @param recordId - UUID of the record + * @param options - Optional {@link EntityV3GetRecordOptions} (`expansionLevel`, `folderKey`). `folderKey` is **experimental**. + * @returns Promise resolving to the record's fields + * @example + * ```typescript + * const record = await entitiesV3.getRecord("Customer", ""); + * ``` + */ + getRecord(entityName: string, recordId: string, options?: EntityV3GetRecordOptions): Promise>; + + /** + * Reads a single record by its business key. + * + * @param entityName - Name of the entity + * @param key - Business key value of the record + * @param options - Optional {@link EntityV3GetRecordOptions} (`expansionLevel`, `folderKey`). `folderKey` is **experimental**. + * @returns Promise resolving to the record's fields + * @example + * ```typescript + * const record = await entitiesV3.getRecordByKey("Customer", "CUST-001"); + * ``` + */ + getRecordByKey(entityName: string, key: string, options?: EntityV3GetRecordOptions): Promise>; + + /** + * Queries records with filters, sorting, aggregates, joins, and pagination. For + * composites, prefix member fields as `Member.Field` and cap children with `childLimit`. + * + * @param entityName - Name of the entity + * @param options - Optional {@link EntityV3QueryOptions}. `folderKey` is **experimental**. + * @returns Promise resolving to an array of records or a paginated response of {@link EntityV3WriteResponse} + * @example + * ```typescript + * import { LogicalOperator, QueryFilterOperator } from '@uipath/uipath-typescript/entities'; + * + * const result = await entitiesV3.query("LoanCaseForBank", { + * selectedFields: ["CaseId", "CaseStatus", "Comments.Comment"], + * filterGroup: { + * logicalOperator: LogicalOperator.And, + * queryFilters: [{ fieldName: "CaseStatus", operator: QueryFilterOperator.Equals, value: "Open" }], + * }, + * childLimit: 100, + * }); + * ``` + */ + query( + entityName: string, + options?: T + ): Promise ? PaginatedResponse : NonPaginatedResponse>; + + /** + * Queries records and returns the result set both as an array and as a + * pre-serialised JSON string. + * + * @param entityName - Name of the entity + * @param options - Optional {@link EntityV3QueryOptions}. `folderKey` is **experimental**. + * @returns Promise resolving to {@link EntityV3QueryExpansionResponse} + * @example + * ```typescript + * const { value, jsonValue } = await entitiesV3.queryWithExpansion("Customer", { expansionLevel: 1 }); + * ``` + */ + queryWithExpansion(entityName: string, options?: EntityV3QueryOptions): Promise; + + /** + * Inserts a single record. For composite entities, include child arrays keyed by + * member instance name; foreign keys are inferred from nesting position. + * + * @param entityName - Name of the entity + * @param data - The record to insert {@link EntityV3RecordInput} + * @param options - Optional {@link EntityV3InsertOptions} (`expansionLevel`, `folderKey`). `folderKey` is **experimental**. + * @returns Promise resolving to the inserted record {@link EntityV3WriteResponse} + * @example + * ```typescript + * // Non-composite + * const rec = await entitiesV3.insert("Customer", { Name: "Alice" }); + * + * // Composite with nested children + * const caseRec = await entitiesV3.insert("LoanCaseForBank", { + * CaseId: "CASE-001", + * CaseStatus: "Open", + * Comments: [{ CommentId: "CMT-001", Comment: "Initial triage" }], + * }); + * ``` + */ + insert(entityName: string, data: EntityV3RecordInput, options?: EntityV3InsertOptions): Promise; + + /** + * Inserts multiple records in a batch (non-composite entities only). + * + * @param entityName - Name of the entity + * @param data - Records to insert + * @param options - Optional {@link EntityV3BatchOptions} (`expansionLevel`, `failOnFirst`, `folderKey`). `folderKey` is **experimental**. + * @returns Promise resolving to {@link EntityV3BatchResponse} + * @example + * ```typescript + * const result = await entitiesV3.insertRecords("Customer", [{ Name: "Alice" }, { Name: "Bob" }]); + * ``` + */ + insertRecords(entityName: string, data: EntityV3RecordInput[], options?: EntityV3BatchOptions): Promise; + + /** + * Bulk-inserts records (fire-and-forget; non-composite entities only). + * + * @param entityName - Name of the entity + * @param data - Records to insert + * @param options - Optional {@link EntityV3InsertBulkOptions} (`folderKey`). `folderKey` is **experimental**. + * @returns Promise resolving to `true` on success + * @example + * ```typescript + * await entitiesV3.insertBulk("Customer", [{ Name: "Alice" }, { Name: "Bob" }]); + * ``` + */ + insertBulk(entityName: string, data: EntityV3RecordInput[], options?: EntityV3InsertBulkOptions): Promise; + + /** + * Inserts or updates a record by business key. For composites, each member record + * is upserted by its business key. + * + * @param entityName - Name of the entity + * @param data - The record to upsert {@link EntityV3RecordInput} + * @param options - Optional {@link EntityV3InsertOptions} (`expansionLevel`, `folderKey`). `folderKey` is **experimental**. + * @returns Promise resolving to the upserted record {@link EntityV3WriteResponse} + * @example + * ```typescript + * await entitiesV3.upsert("Customer", { CustomerId: "CUST-001", Name: "Alice" }); + * ``` + */ + upsert(entityName: string, data: EntityV3RecordInput, options?: EntityV3InsertOptions): Promise; + + /** + * Updates a record by system Id. For composites, root fields are partially updated + * and child arrays are upserted by business key. + * + * @param entityName - Name of the entity + * @param recordId - UUID of the record to update + * @param data - Fields to set (and, for composites, child arrays) {@link EntityV3RecordInput} + * @param options - Optional {@link EntityV3UpdateOptions} (`expansionLevel`, `folderKey`). `folderKey` is **experimental**. + * @returns Promise resolving to the post-update snapshot {@link EntityV3WriteResponse} + * @example + * ```typescript + * await entitiesV3.update("Customer", "", { Name: "Alice B." }); + * ``` + */ + update(entityName: string, recordId: string, data: EntityV3RecordInput, options?: EntityV3UpdateOptions): Promise; + + /** + * Updates a record by business key (see {@link update} for composite semantics). + * + * @param entityName - Name of the entity + * @param key - Business key value of the record to update + * @param data - Fields to set {@link EntityV3RecordInput} + * @param options - Optional {@link EntityV3UpdateOptions} (`expansionLevel`, `folderKey`). `folderKey` is **experimental**. + * @returns Promise resolving to the post-update snapshot {@link EntityV3WriteResponse} + * @example + * ```typescript + * await entitiesV3.updateByKey("Customer", "CUST-001", { Name: "Alice B." }); + * ``` + */ + updateByKey(entityName: string, key: string, data: EntityV3RecordInput, options?: EntityV3UpdateOptions): Promise; + + /** + * Updates multiple records in a batch (non-composite entities only). + * + * @param entityName - Name of the entity + * @param data - Records to update (each must include its `Id`) + * @param options - Optional {@link EntityV3BatchOptions} (`expansionLevel`, `failOnFirst`, `folderKey`). `folderKey` is **experimental**. + * @returns Promise resolving to {@link EntityV3BatchResponse} + * @example + * ```typescript + * await entitiesV3.updateRecords("Customer", [{ Id: "", Name: "Alice B." }]); + * ``` + */ + updateRecords(entityName: string, data: EntityV3RecordInput[], options?: EntityV3BatchOptions): Promise; + + /** + * Updates every record matching a filter with the same field values. For composites, + * the filter and field values must target a single member. + * + * @param entityName - Name of the entity + * @param request - Filter and field values to apply {@link EntityV3ConditionalUpdateRequest} + * @param options - Optional {@link EntityV3UpdateWhereOptions} (`folderKey`). `folderKey` is **experimental**. + * @returns Promise resolving to the write result {@link EntityV3WriteResponse} + * @example + * ```typescript + * import { QueryFilterOperator } from '@uipath/uipath-typescript/entities'; + * + * await entitiesV3.updateWhere("Customer", { + * filterGroup: { queryFilters: [{ fieldName: "Status", operator: QueryFilterOperator.Equals, value: "Trial" }] }, + * fieldValues: { Status: "Active" }, + * }); + * ``` + */ + updateWhere(entityName: string, request: EntityV3ConditionalUpdateRequest, options?: EntityV3UpdateWhereOptions): Promise; + + /** + * Deletes a single record by system Id. For composites, this cascades to all children. + * + * @param entityName - Name of the entity + * @param recordId - UUID of the record to delete + * @param options - Optional {@link EntityV3DeleteOptions} (`folderKey`). `folderKey` is **experimental**. + * @returns Promise resolving to the delete result {@link EntityV3WriteResponse} + * @example + * ```typescript + * await entitiesV3.deleteRecord("Customer", ""); + * ``` + */ + deleteRecord(entityName: string, recordId: string, options?: EntityV3DeleteOptions): Promise; + + /** + * Deletes multiple records by system Id in a single transaction. For composites, + * this cascades to all children. + * + * @param entityName - Name of the entity + * @param recordIds - System Ids (GUIDs) of the records to delete + * @param options - Optional {@link EntityV3DeleteOptions} (`folderKey`). `folderKey` is **experimental**. + * @returns Promise resolving to the delete result {@link EntityV3WriteResponse} + * @example + * ```typescript + * await entitiesV3.deleteRecords("Customer", ["", ""]); + * ``` + */ + deleteRecords(entityName: string, recordIds: string[], options?: EntityV3DeleteOptions): Promise; + + /** + * Deletes multiple records in a batch, returning per-record success/failure + * (non-composite entities only). + * + * @param entityName - Name of the entity + * @param recordIds - System Ids (GUIDs) of the records to delete + * @param options - Optional {@link EntityV3DeleteBatchOptions} (`failOnFirst`, `folderKey`). `folderKey` is **experimental**. + * @returns Promise resolving to {@link EntityV3BatchResponse} + * @example + * ```typescript + * await entitiesV3.deleteRecordsBatch("Customer", ["", ""]); + * ``` + */ + deleteRecordsBatch(entityName: string, recordIds: string[], options?: EntityV3DeleteBatchOptions): Promise; + + /** + * Queries a composite entity's member table directly. Member fields are local — + * no `Member.` prefix needed. + * + * @param entityName - Name of the composite entity + * @param memberName - Instance name of the member + * @param options - Optional {@link EntityV3MemberQueryOptions}. `folderKey` is **experimental**. + * @returns Promise resolving to a paginated response or array of member records + * @example + * ```typescript + * const comments = await entitiesV3.queryMember("LoanCaseForBank", "Comments", { pageSize: 50 }); + * ``` + */ + queryMember( + entityName: string, + memberName: string, + options?: T + ): Promise ? PaginatedResponse> : NonPaginatedResponse>>; + + /** + * Reads a page of a composite entity's member records. + * + * @param entityName - Name of the composite entity + * @param memberName - Instance name of the member + * @param options - Optional {@link EntityV3MemberReadOptions} (pagination, `folderKey`). `folderKey` is **experimental**. + * @returns Promise resolving to a paginated response or array of member records + * @example + * ```typescript + * const comments = await entitiesV3.getMemberRecords("LoanCaseForBank", "Comments"); + * ``` + */ + getMemberRecords( + entityName: string, + memberName: string, + options?: T + ): Promise ? PaginatedResponse> : NonPaginatedResponse>>; + + /** + * Reads a single member record by system Id. + * + * @param entityName - Name of the composite entity + * @param memberName - Instance name of the member + * @param recordId - UUID of the member record + * @param options - Optional {@link EntityV3MemberGetRecordOptions} (`folderKey`). `folderKey` is **experimental**. + * @returns Promise resolving to the member record's fields + * @example + * ```typescript + * const comment = await entitiesV3.getMemberRecord("LoanCaseForBank", "Comments", ""); + * ``` + */ + getMemberRecord(entityName: string, memberName: string, recordId: string, options?: EntityV3MemberGetRecordOptions): Promise>; + + /** + * Reads a single member record by business key. + * + * @param entityName - Name of the composite entity + * @param memberName - Instance name of the member + * @param key - Business key value of the member record + * @param options - Optional {@link EntityV3MemberGetRecordOptions} (`folderKey`). `folderKey` is **experimental**. + * @returns Promise resolving to the member record's fields + * @example + * ```typescript + * const comment = await entitiesV3.getMemberRecordByKey("LoanCaseForBank", "Comments", "CMT-001"); + * ``` + */ + getMemberRecordByKey(entityName: string, memberName: string, key: string, options?: EntityV3MemberGetRecordOptions): Promise>; + + /** + * Deletes a single member record by system Id. Cascades to that member's children. + * + * @param entityName - Name of the composite entity + * @param memberName - Instance name of the member + * @param recordId - UUID of the member record to delete + * @param options - Optional {@link EntityV3DeleteOptions} (`folderKey`). `folderKey` is **experimental**. + * @returns Promise resolving to {@link EntityV3MemberDeleteResponse} + * @example + * ```typescript + * await entitiesV3.deleteMemberRecord("LoanCaseForBank", "Comments", ""); + * ``` + */ + deleteMemberRecord(entityName: string, memberName: string, recordId: string, options?: EntityV3DeleteOptions): Promise; + + /** + * Deletes multiple member records by system Id. Cascades to their children. + * + * @param entityName - Name of the composite entity + * @param memberName - Instance name of the member + * @param recordIds - System Ids (GUIDs) of the member records to delete + * @param options - Optional {@link EntityV3DeleteOptions} (`folderKey`). `folderKey` is **experimental**. + * @returns Promise resolving to {@link EntityV3MemberDeleteResponse} + * @example + * ```typescript + * await entitiesV3.deleteMemberRecords("LoanCaseForBank", "Comments", ["", ""]); + * ``` + */ + deleteMemberRecords(entityName: string, memberName: string, recordIds: string[], options?: EntityV3DeleteOptions): Promise; + + /** + * Adds a field to a composite member table. + * + * @param entityName - Name of the composite entity + * @param memberName - Instance name of the member + * @param request - Field definition {@link EntityV3FieldCreateRequest} + * @param options - Optional {@link EntityV3SchemaOptions} (`folderKey`). `folderKey` is **experimental**. + * @returns Promise resolving to the new field's UUID + * @example + * ```typescript + * import { EntityFieldDataType } from '@uipath/uipath-typescript/entities'; + * + * await entitiesV3.createMemberField("LoanCaseForBank", "Comments", { + * fieldDefinition: { name: "Priority", fieldDataType: { name: EntityFieldDataType.STRING } }, + * }); + * ``` + */ + createMemberField(entityName: string, memberName: string, request: EntityV3FieldCreateRequest, options?: EntityV3SchemaOptions): Promise; + + /** + * Updates the metadata of a field on a composite member table. + * + * @param entityName - Name of the composite entity + * @param memberName - Instance name of the member + * @param fieldId - UUID of the field + * @param request - Field metadata changes {@link EntityV3FieldUpdateRequest} + * @param options - Optional {@link EntityV3SchemaOptions} (`folderKey`). `folderKey` is **experimental**. + * @returns Promise resolving to `true` on success + * @example + * ```typescript + * await entitiesV3.updateMemberField("LoanCaseForBank", "Comments", "", { displayName: "Priority Level" }); + * ``` + */ + updateMemberField(entityName: string, memberName: string, fieldId: string, request: EntityV3FieldUpdateRequest, options?: EntityV3SchemaOptions): Promise; + + /** + * Soft-deletes a field on a composite member table (preserves the column for recovery). + * + * @param entityName - Name of the composite entity + * @param memberName - Instance name of the member + * @param fieldId - UUID of the field + * @param options - Optional {@link EntityV3SchemaOptions} (`folderKey`). `folderKey` is **experimental**. + * @returns Promise resolving to `true` on success + * @example + * ```typescript + * await entitiesV3.deleteMemberField("LoanCaseForBank", "Comments", ""); + * ``` + */ + deleteMemberField(entityName: string, memberName: string, fieldId: string, options?: EntityV3SchemaOptions): Promise; + + /** + * Hard-deletes a field on a composite member table (drops the underlying column). + * + * @param entityName - Name of the composite entity + * @param memberName - Instance name of the member + * @param fieldId - UUID of the field + * @param options - Optional {@link EntityV3SchemaOptions} (`folderKey`). `folderKey` is **experimental**. + * @returns Promise resolving to `true` on success + * @example + * ```typescript + * await entitiesV3.deleteMemberFieldHard("LoanCaseForBank", "Comments", ""); + * ``` + */ + deleteMemberFieldHard(entityName: string, memberName: string, fieldId: string, options?: EntityV3SchemaOptions): Promise; + + /** + * Downloads an attachment from a File-type field of a record. + * + * @param entityName - Name of the entity + * @param recordId - UUID of the record + * @param fieldName - Name of the File-type field + * @param options - Optional {@link EntityV3DownloadAttachmentOptions} (`folderKey`). `folderKey` is **experimental**. + * @returns Promise resolving to a Blob with the file content + * @example + * ```typescript + * const blob = await entitiesV3.downloadAttachment("Customer", "", "Avatar"); + * ``` + */ + downloadAttachment(entityName: string, recordId: string, fieldName: string, options?: EntityV3DownloadAttachmentOptions): Promise; + + /** + * Uploads an attachment to a File-type field of a record. + * + * @param entityName - Name of the entity + * @param recordId - UUID of the record + * @param fieldName - Name of the File-type field + * @param file - File to upload (Blob, File, or Uint8Array) + * @param options - Optional {@link EntityV3UploadAttachmentOptions} (`expansionLevel`, `folderKey`). `folderKey` is **experimental**. + * @returns Promise resolving to the updated record fields + * @example + * ```typescript + * await entitiesV3.uploadAttachment("Customer", "", "Avatar", file); + * ``` + */ + uploadAttachment(entityName: string, recordId: string, fieldName: string, file: EntityFileType, options?: EntityV3UploadAttachmentOptions): Promise>; + + /** + * Removes an attachment from a File-type field of a record. + * + * @param entityName - Name of the entity + * @param recordId - UUID of the record + * @param fieldName - Name of the File-type field + * @param options - Optional {@link EntityV3DeleteAttachmentOptions} (`expansionLevel`, `folderKey`). `folderKey` is **experimental**. + * @returns Promise resolving to the updated record fields + * @example + * ```typescript + * await entitiesV3.deleteAttachment("Customer", "", "Avatar"); + * ``` + */ + deleteAttachment(entityName: string, recordId: string, fieldName: string, options?: EntityV3DeleteAttachmentOptions): Promise>; + + /** + * AI-assisted entity management (autopilot). Sends a natural-language instruction + * and returns the assistant's action. + * + * @param request - Autopilot request {@link EntityV3AutopilotRequest} + * @returns Promise resolving to {@link EntityV3AutopilotResponse} + * @example + * ```typescript + * const result = await entitiesV3.manageWithAutopilot({ query: "Create a Customers entity with a name field" }); + * ``` + */ + manageWithAutopilot(request: EntityV3AutopilotRequest): Promise; + + /** + * Streaming variant of {@link manageWithAutopilot}. Returns the raw response body + * as a stream of server-sent-event chunks for the caller to consume incrementally. + * + * @param request - Autopilot request {@link EntityV3AutopilotRequest} + * @returns Promise resolving to a `ReadableStream` of the response body + * @example + * ```typescript + * const stream = await entitiesV3.manageWithAutopilotStream({ query: "Add a status field" }); + * const reader = stream.getReader(); + * const decoder = new TextDecoder(); + * for (let { done, value } = await reader.read(); !done; { done, value } = await reader.read()) { + * console.log(decoder.decode(value)); + * } + * ``` + */ + manageWithAutopilotStream(request: EntityV3AutopilotRequest): Promise>; +} + +/** + * Operation methods bound to an entity retrieved via {@link EntityV3ServiceModel.getById} + * or {@link EntityV3ServiceModel.getMetadata}. The entity's name and id are captured, so + * callers never re-supply them. + * + * Note: documentation for these operations lives on {@link EntityV3ServiceModel}; only that + * interface is rendered in the API docs. + */ +export interface EntityV3Methods { + getRecords(options?: T): Promise ? PaginatedResponse : NonPaginatedResponse>; + getRecord(recordId: string, options?: EntityV3GetRecordOptions): Promise>; + getRecordByKey(key: string, options?: EntityV3GetRecordOptions): Promise>; + query(options?: T): Promise ? PaginatedResponse : NonPaginatedResponse>; + queryWithExpansion(options?: EntityV3QueryOptions): Promise; + insert(data: EntityV3RecordInput, options?: EntityV3InsertOptions): Promise; + insertRecords(data: EntityV3RecordInput[], options?: EntityV3BatchOptions): Promise; + insertBulk(data: EntityV3RecordInput[], options?: EntityV3InsertBulkOptions): Promise; + upsert(data: EntityV3RecordInput, options?: EntityV3InsertOptions): Promise; + update(recordId: string, data: EntityV3RecordInput, options?: EntityV3UpdateOptions): Promise; + updateByKey(key: string, data: EntityV3RecordInput, options?: EntityV3UpdateOptions): Promise; + updateRecords(data: EntityV3RecordInput[], options?: EntityV3BatchOptions): Promise; + updateWhere(request: EntityV3ConditionalUpdateRequest, options?: EntityV3UpdateWhereOptions): Promise; + deleteRecord(recordId: string, options?: EntityV3DeleteOptions): Promise; + deleteRecords(recordIds: string[], options?: EntityV3DeleteOptions): Promise; + deleteRecordsBatch(recordIds: string[], options?: EntityV3DeleteBatchOptions): Promise; + queryMember(memberName: string, options?: T): Promise ? PaginatedResponse> : NonPaginatedResponse>>; + getMemberRecords(memberName: string, options?: T): Promise ? PaginatedResponse> : NonPaginatedResponse>>; + getMemberRecord(memberName: string, recordId: string, options?: EntityV3MemberGetRecordOptions): Promise>; + getMemberRecordByKey(memberName: string, key: string, options?: EntityV3MemberGetRecordOptions): Promise>; + deleteMemberRecord(memberName: string, recordId: string, options?: EntityV3DeleteOptions): Promise; + deleteMemberRecords(memberName: string, recordIds: string[], options?: EntityV3DeleteOptions): Promise; + createMemberField(memberName: string, request: EntityV3FieldCreateRequest, options?: EntityV3SchemaOptions): Promise; + updateMemberField(memberName: string, fieldId: string, request: EntityV3FieldUpdateRequest, options?: EntityV3SchemaOptions): Promise; + deleteMemberField(memberName: string, fieldId: string, options?: EntityV3SchemaOptions): Promise; + deleteMemberFieldHard(memberName: string, fieldId: string, options?: EntityV3SchemaOptions): Promise; + downloadAttachment(recordId: string, fieldName: string, options?: EntityV3DownloadAttachmentOptions): Promise; + uploadAttachment(recordId: string, fieldName: string, file: EntityFileType, options?: EntityV3UploadAttachmentOptions): Promise>; + deleteAttachment(recordId: string, fieldName: string, options?: EntityV3DeleteAttachmentOptions): Promise>; + createField(request: EntityV3FieldCreateRequest, options?: EntityV3SchemaOptions): Promise; + updateField(fieldId: string, request: EntityV3FieldUpdateRequest, options?: EntityV3SchemaOptions): Promise; + deleteField(fieldId: string, options?: EntityV3SchemaOptions): Promise; + updateMetadata(request: EntityV3UpdateMetadataRequest, options?: EntityV3SchemaOptions): Promise; + delete(options?: EntityV3SchemaOptions): Promise; +} + +/** + * Entity metadata combined with bound operation methods. + */ +export type EntityV3GetResponse = EntityV3Metadata & EntityV3Methods; + +/** + * Builds the bound operation methods for an entity, delegating to the service and + * supplying the entity's captured `name` (data ops) and `id` (schema ops). + */ +function createEntityV3Methods(entityData: EntityV3Metadata, service: EntityV3ServiceModel): EntityV3Methods { + const requireName = (): string => { + if (!entityData.name) throw new Error('Entity name is undefined'); + return entityData.name; + }; + const requireId = (): string => { + if (!entityData.id) throw new Error('Entity ID is undefined'); + return entityData.id; + }; + + return { + async getRecords(options) { + return service.getRecords(requireName(), options); + }, + async getRecord(recordId, options) { + if (!recordId) throw new Error('Record ID is undefined'); + return service.getRecord(requireName(), recordId, options); + }, + async getRecordByKey(key, options) { + if (!key) throw new Error('Record key is undefined'); + return service.getRecordByKey(requireName(), key, options); + }, + async query(options) { + return service.query(requireName(), options); + }, + async queryWithExpansion(options) { + return service.queryWithExpansion(requireName(), options); + }, + async insert(data, options) { + return service.insert(requireName(), data, options); + }, + async insertRecords(data, options) { + return service.insertRecords(requireName(), data, options); + }, + async insertBulk(data, options) { + return service.insertBulk(requireName(), data, options); + }, + async upsert(data, options) { + return service.upsert(requireName(), data, options); + }, + async update(recordId, data, options) { + if (!recordId) throw new Error('Record ID is undefined'); + return service.update(requireName(), recordId, data, options); + }, + async updateByKey(key, data, options) { + if (!key) throw new Error('Record key is undefined'); + return service.updateByKey(requireName(), key, data, options); + }, + async updateRecords(data, options) { + return service.updateRecords(requireName(), data, options); + }, + async updateWhere(request, options) { + return service.updateWhere(requireName(), request, options); + }, + async deleteRecord(recordId, options) { + if (!recordId) throw new Error('Record ID is undefined'); + return service.deleteRecord(requireName(), recordId, options); + }, + async deleteRecords(recordIds, options) { + return service.deleteRecords(requireName(), recordIds, options); + }, + async deleteRecordsBatch(recordIds, options) { + return service.deleteRecordsBatch(requireName(), recordIds, options); + }, + async queryMember(memberName, options) { + if (!memberName) throw new Error('Member name is undefined'); + return service.queryMember(requireName(), memberName, options); + }, + async getMemberRecords(memberName, options) { + if (!memberName) throw new Error('Member name is undefined'); + return service.getMemberRecords(requireName(), memberName, options); + }, + async getMemberRecord(memberName, recordId, options) { + if (!memberName) throw new Error('Member name is undefined'); + if (!recordId) throw new Error('Record ID is undefined'); + return service.getMemberRecord(requireName(), memberName, recordId, options); + }, + async getMemberRecordByKey(memberName, key, options) { + if (!memberName) throw new Error('Member name is undefined'); + if (!key) throw new Error('Record key is undefined'); + return service.getMemberRecordByKey(requireName(), memberName, key, options); + }, + async deleteMemberRecord(memberName, recordId, options) { + if (!memberName) throw new Error('Member name is undefined'); + if (!recordId) throw new Error('Record ID is undefined'); + return service.deleteMemberRecord(requireName(), memberName, recordId, options); + }, + async deleteMemberRecords(memberName, recordIds, options) { + if (!memberName) throw new Error('Member name is undefined'); + return service.deleteMemberRecords(requireName(), memberName, recordIds, options); + }, + async createMemberField(memberName, request, options) { + if (!memberName) throw new Error('Member name is undefined'); + return service.createMemberField(requireName(), memberName, request, options); + }, + async updateMemberField(memberName, fieldId, request, options) { + if (!memberName) throw new Error('Member name is undefined'); + if (!fieldId) throw new Error('Field ID is undefined'); + return service.updateMemberField(requireName(), memberName, fieldId, request, options); + }, + async deleteMemberField(memberName, fieldId, options) { + if (!memberName) throw new Error('Member name is undefined'); + if (!fieldId) throw new Error('Field ID is undefined'); + return service.deleteMemberField(requireName(), memberName, fieldId, options); + }, + async deleteMemberFieldHard(memberName, fieldId, options) { + if (!memberName) throw new Error('Member name is undefined'); + if (!fieldId) throw new Error('Field ID is undefined'); + return service.deleteMemberFieldHard(requireName(), memberName, fieldId, options); + }, + async downloadAttachment(recordId, fieldName, options) { + if (!recordId) throw new Error('Record ID is undefined'); + return service.downloadAttachment(requireName(), recordId, fieldName, options); + }, + async uploadAttachment(recordId, fieldName, file, options) { + if (!recordId) throw new Error('Record ID is undefined'); + return service.uploadAttachment(requireName(), recordId, fieldName, file, options); + }, + async deleteAttachment(recordId, fieldName, options) { + if (!recordId) throw new Error('Record ID is undefined'); + return service.deleteAttachment(requireName(), recordId, fieldName, options); + }, + async createField(request, options) { + return service.createField(requireId(), request, options); + }, + async updateField(fieldId, request, options) { + if (!fieldId) throw new Error('Field ID is undefined'); + return service.updateField(requireId(), fieldId, request, options); + }, + async deleteField(fieldId, options) { + if (!fieldId) throw new Error('Field ID is undefined'); + return service.deleteField(requireId(), fieldId, options); + }, + async updateMetadata(request, options) { + return service.updateMetadata(requireId(), request, options); + }, + async delete(options) { + return service.deleteById(requireId(), options); + }, + }; +} + +/** + * Combines entity metadata with bound operation methods. + * + * @param entityData - Entity metadata + * @param service - The v3 entity service instance + * @returns Entity metadata with bound methods attached + */ +export function createEntityV3WithMethods( + entityData: EntityV3Metadata, + service: EntityV3ServiceModel +): EntityV3GetResponse { + const methods = createEntityV3Methods(entityData, service); + return Object.assign({}, entityData, methods) as EntityV3GetResponse; +} diff --git a/src/models/data-fabric/entities-v3.types.ts b/src/models/data-fabric/entities-v3.types.ts new file mode 100644 index 000000000..0ea003d24 --- /dev/null +++ b/src/models/data-fabric/entities-v3.types.ts @@ -0,0 +1,550 @@ +/** + * Public types for the Data Fabric Entity **v3** API. + * + * v3 adds composite-entity support (a logical entity backed by multiple related + * "member" tables) on top of the v1/v2 surface. Every v3 read/query returns a + * {@link EntityV3QueryResponse} envelope and every write returns an + * {@link EntityV3WriteResponse} envelope. For non-composite entities the + * `children` map is empty; for composite entities it is keyed by member + * instance name. + * + * Record field values are returned exactly as the API sends them (Data Fabric + * schema columns are user-defined — their casing is part of the schema contract + * and is never transformed). + */ + +import { PaginationOptions } from '../../utils/pagination/types'; +import { + EntityQueryFilterGroup, + EntityQuerySortOption, + EntityAggregate, + EntityJoin, + EntityFieldDataType, + SqlType, +} from './entities.types'; +import { EntityFolderScopedOptions } from './data-fabric.types'; + +// --------------------------------------------------------------------------- +// Response envelopes +// --------------------------------------------------------------------------- + +/** + * A page of a composite entity's child (member) records, returned inside the + * `children` map of an {@link EntityV3WriteResponse}. For non-composite entities + * the `children` map is empty and this type never appears. + */ +export interface ChildArrayBlock { + /** The child records in this block. Field names are returned exactly as stored. */ + records: Record[]; + /** `true` when more child records exist than were returned — use {@link ref} to fetch the rest. */ + hasMore: boolean; + /** + * A ready-to-use member sub-resource query descriptor for fetching the + * remaining children. `null` when {@link hasMore} is `false`. + */ + ref?: ChildArrayPaginationRef | null; +} + +/** + * A server-provided descriptor for paginating a composite entity's child records + * via the member sub-resource query endpoint. + */ +export interface ChildArrayPaginationRef { + /** Relative URL of the member sub-resource query endpoint. */ + queryUrl: string; + /** Request body to POST to {@link queryUrl} to fetch the next block of children. */ + queryRequest: Record; +} + +/** + * Universal v3 write/record envelope. Root entity field values are flattened to + * the top level alongside the envelope properties. For non-composite entities + * `children` is an empty map; for composite entities it is keyed by member + * instance name. + * + * @example + * ```typescript + * // Non-composite record + * { Id: "guid-1", Name: "Alice", Email: "alice@example.com", children: {} } + * + * // Composite record with children + * { Id: "guid-1", CaseId: "CASE-001", children: { Comments: { records: [...], hasMore: false } } } + * ``` + */ +export interface EntityV3WriteResponse { + /** System Id (GUID) of the root record. Always present on read/write responses. */ + Id: string; + /** Child records keyed by member instance name. Empty map for non-composite entities. */ + children: Record; + /** Cascade-delete counts per member instance name. Present only on delete responses. */ + cascadeDeletedChildren?: Record; + /** Number of records deleted by a delete operation. */ + deletedCount?: number; + /** Number of records updated by an update operation. */ + updatedCount?: number; + /** Root entity field values, flattened to the top level. Field names are returned exactly as stored. */ + [field: string]: unknown; +} + +/** + * Universal v3 query/list response envelope. Returned by every v3 read/query + * endpoint on the composite entity itself. + */ +export interface EntityV3QueryResponse { + /** The page of records returned by this query. */ + value: EntityV3WriteResponse[]; + /** Total number of root records matching the query (before pagination). */ + totalRecordCount: number; + /** + * `true` when a single-JOIN fallback was used because cross-entity filters were + * present, in which case {@link totalRecordCount} is an estimate. `false` for + * standard queries. + */ + totalRecordCountIsEstimate?: boolean; +} + +/** + * Response from the `query_expansion` endpoint. Returns the records both as a + * pre-serialised JSON string ({@link jsonValue}) and as an array. + */ +export interface EntityV3QueryExpansionResponse { + /** The full result set serialised as a JSON string. */ + jsonValue?: string; + /** Total number of records matching the query (before pagination). */ + totalRecordCount: number; + /** The page of records returned by this query. Field names are returned exactly as stored. */ + value: Record[]; +} + +/** + * Response from deleting one or more member records. + */ +export interface EntityV3MemberDeleteResponse { + /** System Id (GUID) of the deleted record for a single delete; omitted for bulk deletes. */ + recordId?: string; + /** Number of records deleted by this operation. */ + deletedCount: number; + /** Cascade-delete counts per member instance name (children of the deleted member). */ + cascadeDeletedChildren?: Record; +} + +/** + * A single failed record in a batch operation. + */ +export interface EntityV3BatchFailureRecord { + /** Error message describing why the record failed. */ + error?: string; + /** The original record that failed. Field names are returned exactly as stored. */ + record?: Record; +} + +/** + * Response from a batch operation (insert-batch / update-batch / delete-batch), + * partitioning results into successes and failures. + */ +export interface EntityV3BatchResponse { + /** Records that were processed successfully. Field names are returned exactly as stored. */ + successRecords: Record[]; + /** Records that failed, each with an error message. */ + failureRecords: EntityV3BatchFailureRecord[]; +} + +// --------------------------------------------------------------------------- +// Schema / metadata responses +// --------------------------------------------------------------------------- + +/** + * One member (underlying table) of a composite entity, as returned in + * {@link EntityV3CompositeInfo}. + */ +export interface EntityV3CompositeMember { + /** GUID of the member entity. */ + entityId: string; + /** Instance name of the member — used as the key in `children` maps and as the member path segment. */ + entityName: string; + /** Human-readable display name of the member. */ + displayName: string; + /** `true` for the root (anchor) member of the composite. */ + isRoot: boolean; + /** Instance name of the parent member, or `null` for the root. */ + parentEntityName?: string | null; + /** Name of the FK field on this member pointing at its parent, or `null` for the root. */ + foreignKeyFieldName?: string | null; + /** Name of the target field on the parent that {@link foreignKeyFieldName} references, or `null` for the root. */ + parentTargetFieldName?: string | null; +} + +/** + * Composite-specific structure describing a composite entity's member tree. + */ +export interface EntityV3CompositeInfo { + /** GUID of the composite entity. */ + entityId: string; + /** Instance name of the root member. */ + rootEntityName?: string | null; + /** All members of the composite, including the root. */ + members?: EntityV3CompositeMember[] | null; +} + +/** + * Entity schema metadata returned by the v3 metadata and listing endpoints. + * When {@link isComposite} is `true`, {@link compositeInfo} describes the member tree. + */ +export interface EntityV3Metadata { + /** GUID of the entity. */ + id: string; + /** Entity name (unique identifier used in data-plane routes). */ + name: string; + /** Human-readable display name. */ + displayName: string; + /** `true` when the entity is a composite (backed by multiple member tables). */ + isComposite?: boolean; + /** Member tree structure, present only when {@link isComposite} is `true`. */ + compositeInfo?: EntityV3CompositeInfo; + /** Classification of the entity (e.g. `"Entity"`, `"CaseComposite"`, `"CompositeMember"`). */ + entityClass?: string | null; + /** Template driving schema validation (e.g. `"Case"`, `"CaseComment"`), when applicable. */ + templateName?: string | null; + /** Optional entity description. */ + description?: string | null; + /** GUID of the folder the entity belongs to. */ + folderId?: string; + /** Field definitions of the entity. */ + fields?: Record[] | null; + /** Number of records stored in the entity. */ + recordCount?: number | null; + /** Whether role-based access control is enabled. */ + isRbacEnabled?: boolean; + /** Whether Insights/Analytics integration is enabled. */ + isInsightsEnabled?: boolean; +} + +/** + * Response from the v3 entity-listing endpoint, which returns entities and + * choice sets side by side. Composite members are hidden from this listing. + */ +export interface EntityV3ListResponse { + /** Entity schema metadata records. */ + entities?: EntityV3Metadata[] | null; + /** Choice sets in scope. */ + choicesets?: Record[] | null; +} + +// --------------------------------------------------------------------------- +// Request bodies for data-plane operations +// --------------------------------------------------------------------------- + +/** + * A single record to insert, update, or upsert. For composite entities, include + * child arrays keyed by member instance name alongside the root fields; foreign + * keys are inferred from nesting position. + * + * @example + * ```typescript + * // Composite insert with nested children + * { + * CaseId: "CASE-001", + * CaseStatus: "Open", + * Comments: [{ CommentId: "CMT-001", Comment: "Initial triage" }] + * } + * ``` + */ +export type EntityV3RecordInput = Record; + +/** + * Conditional (filtered) update request for `updateWhere`. Every matching record + * has {@link fieldValues} applied. For composite entities the filter and field + * values must target a single member via the `Member.Field` prefix convention. + */ +export interface EntityV3ConditionalUpdateRequest { + /** Filter selecting which records to update. Omit to match all records. */ + filterGroup?: EntityQueryFilterGroup; + /** Field values to set on every matching record. */ + fieldValues: Record; +} + +// --------------------------------------------------------------------------- +// Options types +// --------------------------------------------------------------------------- + +/** Options for listing v3 entities. */ +export interface EntityV3GetAllOptions extends EntityFolderScopedOptions { + /** Filter the listing by entity classification (e.g. `"CaseComposite"`). */ + entityClass?: string; +} + +/** Options for the `all` listing endpoint (entities + choice sets), which uses a raw start/limit window. */ +export interface EntityV3GetAllPagedOptions extends EntityFolderScopedOptions { + /** Zero-based offset of the first entity to return (default: 0). */ + start?: number; + /** Maximum number of entities to return (default: 1000). */ + limit?: number; +} + +/** Options carrying the expansion level for composite child data (default: 0). */ +export interface EntityV3ExpansionOptions extends EntityFolderScopedOptions { + /** Depth of composite member expansion to include in the response (default: 0). */ + expansionLevel?: number; +} + +/** Options for reading a page of records with `getRecords`. */ +export type EntityV3ReadOptions = { + /** Depth of composite member expansion to include in the response (default: 0). */ + expansionLevel?: number; +} & PaginationOptions & EntityFolderScopedOptions; + +/** Options for reading a single record by id or key. */ +export interface EntityV3GetRecordOptions extends EntityV3ExpansionOptions {} + +/** + * Options for querying records. Extends the standard query surface (filters, + * sorting, aggregates, joins, pagination) with a per-root cap on child records. + */ +export type EntityV3QueryOptions = { + /** Filter conditions to apply. */ + filterGroup?: EntityQueryFilterGroup; + /** Field names to include (returns all fields if omitted). For composites, prefix member fields as `Member.Field`. */ + selectedFields?: string[]; + /** Sort options for the results. */ + sortOptions?: EntityQuerySortOption[]; + /** Depth of composite member expansion to include (default: 0). */ + expansionLevel?: number; + /** Aggregate expressions (COUNT, SUM, AVG, MIN, MAX) to apply. */ + aggregates?: EntityAggregate[]; + /** Field names to group aggregate results by. */ + groupBy?: string[]; + /** Cross-entity joins (max 3, all the same join type). */ + joins?: EntityJoin[]; + /** Maximum child records returned per root record per member type. */ + childLimit?: number; +} & PaginationOptions & EntityFolderScopedOptions; + +/** Options for inserting a single record. */ +export interface EntityV3InsertOptions extends EntityV3ExpansionOptions {} + +/** Options for batch insert/update operations. */ +export interface EntityV3BatchOptions extends EntityV3ExpansionOptions { + /** Whether to stop at the first failing record (default: false). */ + failOnFirst?: boolean; +} + +/** Options for updating a single record. */ +export interface EntityV3UpdateOptions extends EntityV3ExpansionOptions {} + +/** Options for deleting records. */ +export interface EntityV3DeleteOptions extends EntityFolderScopedOptions {} + +/** Options for bulk insert (folder scope only). */ +export interface EntityV3InsertBulkOptions extends EntityFolderScopedOptions {} + +/** Options for a conditional update-where (folder scope only). */ +export interface EntityV3UpdateWhereOptions extends EntityFolderScopedOptions {} + +/** Options for batch delete. */ +export interface EntityV3DeleteBatchOptions extends EntityFolderScopedOptions { + /** Whether to stop at the first failing record (default: false). */ + failOnFirst?: boolean; +} + +/** Options for reading a page of member records. */ +export type EntityV3MemberReadOptions = PaginationOptions & EntityFolderScopedOptions; + +/** Options for reading a single member record by id or key. */ +export interface EntityV3MemberGetRecordOptions extends EntityFolderScopedOptions {} + +/** + * Options for querying member records. Member fields are local (no prefix needed), + * so this is the standard query surface without `childLimit`. + */ +export type EntityV3MemberQueryOptions = { + /** Filter conditions to apply. */ + filterGroup?: EntityQueryFilterGroup; + /** Field names to include (returns all fields if omitted). */ + selectedFields?: string[]; + /** Sort options for the results. */ + sortOptions?: EntityQuerySortOption[]; + /** Aggregate expressions to apply. */ + aggregates?: EntityAggregate[]; + /** Field names to group aggregate results by. */ + groupBy?: string[]; +} & PaginationOptions & EntityFolderScopedOptions; + +/** Options for uploading an attachment. */ +export interface EntityV3UploadAttachmentOptions extends EntityV3ExpansionOptions {} + +/** Options for downloading an attachment. */ +export interface EntityV3DownloadAttachmentOptions extends EntityFolderScopedOptions {} + +/** Options for deleting an attachment. */ +export interface EntityV3DeleteAttachmentOptions extends EntityV3ExpansionOptions {} + +/** Options for reading/creating/deleting entity schema. */ +export interface EntityV3SchemaOptions extends EntityFolderScopedOptions {} + +// --------------------------------------------------------------------------- +// Schema-write request bodies +// --------------------------------------------------------------------------- + +/** + * Wire-shaped field definition for schema-write operations (create entity / + * create field). Reuses the API's field-definition shape. + */ +export interface EntityV3FieldDefinition { + /** Field name — starts with a letter, letters/numbers/underscores only. */ + name: string; + /** Human-readable display name (defaults to `name`). */ + displayName?: string; + /** Optional field description. */ + description?: string; + /** Whether the field is required. */ + isRequired?: boolean; + /** Whether the field value must be unique. */ + isUnique?: boolean; + /** Whether the field is a foreign key referencing another member (composite create). */ + isForeignKey?: boolean; + /** For FK fields: instance name of the referenced member within the create request. */ + referencesEntityName?: string; + /** For FK fields: name of the target field on the referenced member. */ + referencesFieldName?: string; + /** Whether the field value is encrypted at rest. */ + isEncrypted?: boolean; + /** Whether role-based access control is enabled for this field. */ + isRbacEnabled?: boolean; + /** Default value for the field. */ + defaultValue?: string; + /** SQL type descriptor (name + optional length/precision/range). */ + sqlType?: SqlType; + /** SDK field data type (alternative to {@link sqlType}). */ + fieldDataType?: { name: EntityFieldDataType }; +} + +/** + * The schema definition for one entity (or composite member) in a create request. + */ +export interface EntityV3EntityDefinition { + /** Entity instance name — unique within the create request. */ + name: string; + /** Entity type. Use a numeric type id or an entity-type string as required by the API. */ + entityType?: number | string; + /** Template driving schema validation (e.g. `"Case"`, `"CaseComment"`), when applicable. */ + templateName?: string; + /** Field definitions for the entity. */ + fields: EntityV3FieldDefinition[]; + /** External field sources — must be empty for composite members. */ + externalFields?: unknown[]; +} + +/** One member entry in a composite create request. */ +export interface EntityV3CreateMember { + /** Human-readable display name of the member. */ + displayName?: string; + /** The member's schema definition. */ + entityDefinition: EntityV3EntityDefinition; +} + +/** + * Request body for creating an entity via v3. Omit `members` to create a single + * entity; include a non-empty `members` array to create a composite entity. + */ +export interface EntityV3CreateRequest { + /** Entity name (composite) — the logical business entity name. */ + name?: string; + /** Human-readable display name. */ + displayName?: string; + /** Optional description. */ + description?: string; + /** GUID of the target folder. */ + folderId?: string; + /** Entity type (numeric id or string). Required for composite creation. */ + entityType?: number | string; + /** Schema definition for a single (non-composite) entity. */ + entityDefinition?: EntityV3EntityDefinition; + /** Member definitions for a composite entity (ordered; root inferred from FK graph). */ + members?: EntityV3CreateMember[]; +} + +/** Response from creating a composite entity. */ +export interface EntityV3CompositeCreateResponse { + /** GUID of the created composite entity. */ + entityId: string; + /** Composite entity name. */ + name: string; + /** Created members keyed by instance name. */ + members: Record; +} + +/** Request body for updating entity metadata. */ +export interface EntityV3UpdateMetadataRequest { + /** New display name (required). */ + displayName: string; + /** New description. */ + description?: string; + /** GUID of the folder the entity belongs to. */ + folderId?: string; + /** Whether role-based access control is enabled. */ + isRbacEnabled?: boolean; + /** Whether Insights/Analytics integration is enabled. */ + isInsightsEnabled?: boolean; +} + +/** Request body for creating a field on an entity or composite member. */ +export interface EntityV3FieldCreateRequest { + /** The field definition to create. */ + fieldDefinition: EntityV3FieldDefinition; +} + +/** Request body for updating field metadata. */ +export interface EntityV3FieldUpdateRequest { + /** New display name. */ + displayName?: string; + /** New description. */ + description?: string; + /** Whether the field is required. */ + isRequired?: boolean; + /** Whether the field value is encrypted at rest. */ + isEncrypted?: boolean; + /** Default value for the field. */ + defaultValue?: string; + /** SQL type descriptor (the SQL type name itself is immutable). */ + sqlType?: SqlType; + /** Whether role-based access control is enabled for this field. */ + isRbacEnabled?: boolean; + /** Whether the field value must be unique. */ + isUnique?: boolean; +} + +// --------------------------------------------------------------------------- +// Autopilot +// --------------------------------------------------------------------------- + +/** + * Request body for AI-assisted entity management (autopilot). The shape mirrors + * the backend contract; pass the conversation, query, and context the assistant + * needs. + */ +export interface EntityV3AutopilotRequest { + /** Prior conversation turns for context. */ + conversations?: Record[]; + /** The natural-language instruction. */ + query?: string; + /** Entity relationship context. */ + entityRelationship?: Record; + /** Integration Service connections available to the assistant. */ + connections?: Record[]; + /** Actions the assistant may take. */ + actions?: Record[]; + /** Additional metadata. */ + metadata?: Record; +} + +/** Response from a non-streaming autopilot `manage` call. */ +export interface EntityV3AutopilotResponse { + /** Whether the operation succeeded. */ + isSuccess: boolean; + /** Human-readable message. */ + message?: string; + /** The action the assistant took. */ + action?: string; + /** Resulting entity relationship context. */ + entityRelationship?: Record; +} diff --git a/src/models/data-fabric/index.ts b/src/models/data-fabric/index.ts index d71bc75e2..837469119 100644 --- a/src/models/data-fabric/index.ts +++ b/src/models/data-fabric/index.ts @@ -1,4 +1,6 @@ export * from './entities.types'; export * from './entities.models'; +export * from './entities-v3.types'; +export * from './entities-v3.models'; export * from './choicesets.types'; export * from './choicesets.models'; diff --git a/src/services/data-fabric/entities-v3.ts b/src/services/data-fabric/entities-v3.ts new file mode 100644 index 000000000..120106887 --- /dev/null +++ b/src/services/data-fabric/entities-v3.ts @@ -0,0 +1,592 @@ +import { ValidationError } from '../../core/errors'; +import { BaseService } from '../base'; +import { + EntityV3ServiceModel, + EntityV3GetResponse, + EntityV3CreateResponse, + createEntityV3WithMethods, +} from '../../models/data-fabric/entities-v3.models'; +import { + EntityV3Metadata, + EntityV3ListResponse, + EntityV3WriteResponse, + EntityV3QueryExpansionResponse, + EntityV3MemberDeleteResponse, + EntityV3BatchResponse, + EntityV3RecordInput, + EntityV3ConditionalUpdateRequest, + EntityV3CreateRequest, + EntityV3UpdateMetadataRequest, + EntityV3FieldCreateRequest, + EntityV3FieldUpdateRequest, + EntityV3AutopilotRequest, + EntityV3AutopilotResponse, + EntityV3GetAllOptions, + EntityV3GetAllPagedOptions, + EntityV3ReadOptions, + EntityV3GetRecordOptions, + EntityV3QueryOptions, + EntityV3InsertOptions, + EntityV3BatchOptions, + EntityV3UpdateOptions, + EntityV3DeleteOptions, + EntityV3InsertBulkOptions, + EntityV3UpdateWhereOptions, + EntityV3DeleteBatchOptions, + EntityV3MemberReadOptions, + EntityV3MemberGetRecordOptions, + EntityV3MemberQueryOptions, + EntityV3UploadAttachmentOptions, + EntityV3DownloadAttachmentOptions, + EntityV3DeleteAttachmentOptions, + EntityV3SchemaOptions, +} from '../../models/data-fabric/entities-v3.types'; +import { EntityFileType } from '../../models/data-fabric/entities.types'; +import { PaginatedResponse, NonPaginatedResponse, HasPaginationOptions } from '../../utils/pagination/types'; +import { PaginationType } from '../../utils/pagination/internal-types'; +import { PaginationHelpers } from '../../utils/pagination/helpers'; +import { ENTITY_PAGINATION, ENTITY_OFFSET_PARAMS, HTTP_METHODS } from '../../utils/constants/common'; +import { DATA_FABRIC_V3_ENDPOINTS } from '../../utils/constants/endpoints/data-fabric'; +import { FOLDER_KEY, RESPONSE_TYPES } from '../../utils/constants/headers'; +import { createHeaders } from '../../utils/http/headers'; +import { createParams } from '../../utils/http/params'; +import { MAX_QUERY_JOINS } from '../../models/data-fabric/entities.constants'; +import { track } from '../../core/telemetry'; + +/** Query/body keys that must not receive the OData `$` prefix on v3 query requests. */ +const V3_QUERY_EXCLUDE_FROM_PREFIX = ['filterGroup', 'selectedFields', 'sortOptions', 'aggregates', 'groupBy', 'joins', 'childLimit']; + +/** Shared OFFSET pagination config for v3 read/query endpoints (items in `value`, total in `totalRecordCount`, params `limit`/`start`). */ +const V3_PAGINATION = { + paginationType: PaginationType.OFFSET, + itemsField: ENTITY_PAGINATION.ITEMS_FIELD, + totalCountField: ENTITY_PAGINATION.TOTAL_COUNT_FIELD, + paginationParams: { + pageSizeParam: ENTITY_OFFSET_PARAMS.PAGE_SIZE_PARAM, + offsetParam: ENTITY_OFFSET_PARAMS.OFFSET_PARAM, + countParam: ENTITY_OFFSET_PARAMS.COUNT_PARAM, + }, +} as const; + +/** + * Service for the Data Fabric Entity **v3** API (composite-entity support). + * + * Data operations are addressed by entity name; schema operations by entity id. + * Record field values are returned exactly as the API sends them. + */ +export class EntityV3Service extends BaseService implements EntityV3ServiceModel { + // ----- Listing / metadata ------------------------------------------------- + + @track('EntitiesV3.GetAll') + async getAll(options?: EntityV3GetAllOptions): Promise { + const response = await this.get( + DATA_FABRIC_V3_ENDPOINTS.ENTITY.LIST, + { + params: createParams({ entityClass: options?.entityClass }), + headers: createHeaders({ [FOLDER_KEY]: options?.folderKey }), + } + ); + return response.data; + } + + @track('EntitiesV3.GetAllWithChoiceSets') + async getAllWithChoiceSets(options?: EntityV3GetAllPagedOptions): Promise { + const response = await this.get( + DATA_FABRIC_V3_ENDPOINTS.ENTITY.LIST_ALL, + { + params: createParams({ start: options?.start, limit: options?.limit }), + headers: createHeaders({ [FOLDER_KEY]: options?.folderKey }), + } + ); + return response.data; + } + + @track('EntitiesV3.GetFolderEntities') + async getFolderEntities(options?: EntityV3GetAllOptions): Promise { + const response = await this.get( + DATA_FABRIC_V3_ENDPOINTS.ENTITY.FOLDER_ENTITIES, + { headers: createHeaders({ [FOLDER_KEY]: options?.folderKey }) } + ); + return response.data; + } + + @track('EntitiesV3.GetById') + async getById(entityId: string, options?: EntityV3SchemaOptions): Promise { + const response = await this.get( + DATA_FABRIC_V3_ENDPOINTS.ENTITY.GET_BY_ID(entityId), + { headers: createHeaders({ [FOLDER_KEY]: options?.folderKey }) } + ); + return createEntityV3WithMethods(response.data, this); + } + + @track('EntitiesV3.GetMetadata') + async getMetadata(entityName: string, options?: EntityV3SchemaOptions): Promise { + const response = await this.get( + DATA_FABRIC_V3_ENDPOINTS.ENTITY.GET_METADATA(entityName), + { headers: createHeaders({ [FOLDER_KEY]: options?.folderKey }) } + ); + return createEntityV3WithMethods(response.data, this); + } + + // ----- Schema CUD --------------------------------------------------------- + + @track('EntitiesV3.Create') + async create(request: EntityV3CreateRequest, options?: EntityV3SchemaOptions): Promise { + const response = await this.post( + DATA_FABRIC_V3_ENDPOINTS.ENTITY.CREATE, + request, + { headers: createHeaders({ [FOLDER_KEY]: options?.folderKey }) } + ); + return response.data; + } + + @track('EntitiesV3.DeleteById') + async deleteById(entityId: string, options?: EntityV3SchemaOptions): Promise { + await this.delete( + DATA_FABRIC_V3_ENDPOINTS.ENTITY.DELETE_BY_ID(entityId), + { headers: createHeaders({ [FOLDER_KEY]: options?.folderKey }) } + ); + } + + @track('EntitiesV3.UpdateMetadata') + async updateMetadata(entityId: string, request: EntityV3UpdateMetadataRequest, options?: EntityV3SchemaOptions): Promise { + const response = await this.patch( + DATA_FABRIC_V3_ENDPOINTS.ENTITY.UPDATE_METADATA(entityId), + { entityId, ...request }, + { headers: createHeaders({ [FOLDER_KEY]: options?.folderKey }) } + ); + return response.data; + } + + @track('EntitiesV3.CreateField') + async createField(entityId: string, request: EntityV3FieldCreateRequest, options?: EntityV3SchemaOptions): Promise { + const response = await this.post( + DATA_FABRIC_V3_ENDPOINTS.ENTITY.FIELD.CREATE(entityId), + { entityId, ...request }, + { headers: createHeaders({ [FOLDER_KEY]: options?.folderKey }) } + ); + return response.data; + } + + @track('EntitiesV3.UpdateField') + async updateField(entityId: string, fieldId: string, request: EntityV3FieldUpdateRequest, options?: EntityV3SchemaOptions): Promise { + const response = await this.patch( + DATA_FABRIC_V3_ENDPOINTS.ENTITY.FIELD.UPDATE(entityId, fieldId), + { id: fieldId, ...request }, + { headers: createHeaders({ [FOLDER_KEY]: options?.folderKey }) } + ); + return response.data; + } + + @track('EntitiesV3.DeleteField') + async deleteField(entityId: string, fieldId: string, options?: EntityV3SchemaOptions): Promise { + const response = await this.delete( + DATA_FABRIC_V3_ENDPOINTS.ENTITY.FIELD.DELETE(entityId, fieldId), + { headers: createHeaders({ [FOLDER_KEY]: options?.folderKey }) } + ); + return response.data; + } + + // ----- Data reads --------------------------------------------------------- + + @track('EntitiesV3.GetRecords') + async getRecords( + entityName: string, + options?: T + ): Promise ? PaginatedResponse : NonPaginatedResponse> { + const { folderKey, ...rest } = options ?? {}; + const downstreamOptions = options === undefined ? undefined : (rest as T); + return PaginationHelpers.getAll({ + serviceAccess: this.createPaginationServiceAccess(), + getEndpoint: () => DATA_FABRIC_V3_ENDPOINTS.ENTITY.READ(entityName), + headers: createHeaders({ [FOLDER_KEY]: folderKey }), + pagination: V3_PAGINATION, + excludeFromPrefix: ['expansionLevel'], + }, downstreamOptions); + } + + @track('EntitiesV3.GetRecord') + async getRecord(entityName: string, recordId: string, options?: EntityV3GetRecordOptions): Promise> { + const response = await this.get>( + DATA_FABRIC_V3_ENDPOINTS.ENTITY.READ_RECORD(entityName, recordId), + { + params: createParams({ expansionLevel: options?.expansionLevel }), + headers: createHeaders({ [FOLDER_KEY]: options?.folderKey }), + } + ); + return response.data; + } + + @track('EntitiesV3.GetRecordByKey') + async getRecordByKey(entityName: string, key: string, options?: EntityV3GetRecordOptions): Promise> { + const response = await this.get>( + DATA_FABRIC_V3_ENDPOINTS.ENTITY.READ_BY_KEY(entityName, key), + { + params: createParams({ expansionLevel: options?.expansionLevel }), + headers: createHeaders({ [FOLDER_KEY]: options?.folderKey }), + } + ); + return response.data; + } + + @track('EntitiesV3.Query') + async query( + entityName: string, + options?: T + ): Promise ? PaginatedResponse : NonPaginatedResponse> { + if (options?.joins && options.joins.length > MAX_QUERY_JOINS) { + throw new ValidationError({ + message: `A maximum of ${MAX_QUERY_JOINS} joins is supported per query (received ${options.joins.length})`, + }); + } + const { folderKey, expansionLevel, ...rest } = options ?? {}; + const downstreamOptions = options === undefined ? undefined : (rest as T); + return PaginationHelpers.getAll({ + serviceAccess: this.createPaginationServiceAccess(), + getEndpoint: () => DATA_FABRIC_V3_ENDPOINTS.ENTITY.QUERY(entityName), + method: HTTP_METHODS.POST, + headers: createHeaders({ [FOLDER_KEY]: folderKey }), + queryParams: createParams({ expansionLevel }), + pagination: V3_PAGINATION, + excludeFromPrefix: V3_QUERY_EXCLUDE_FROM_PREFIX, + }, downstreamOptions); + } + + @track('EntitiesV3.QueryWithExpansion') + async queryWithExpansion(entityName: string, options?: EntityV3QueryOptions): Promise { + if (options?.joins && options.joins.length > MAX_QUERY_JOINS) { + throw new ValidationError({ + message: `A maximum of ${MAX_QUERY_JOINS} joins is supported per query (received ${options.joins.length})`, + }); + } + // query_expansion is not SDK-paginated — send only the query body fields. + const body = { + filterGroup: options?.filterGroup, + selectedFields: options?.selectedFields, + sortOptions: options?.sortOptions, + aggregates: options?.aggregates, + groupBy: options?.groupBy, + joins: options?.joins, + childLimit: options?.childLimit, + }; + const response = await this.post( + DATA_FABRIC_V3_ENDPOINTS.ENTITY.QUERY_EXPANSION(entityName), + body, + { + params: createParams({ expansionLevel: options?.expansionLevel }), + headers: createHeaders({ [FOLDER_KEY]: options?.folderKey }), + } + ); + return response.data; + } + + // ----- Data writes -------------------------------------------------------- + + @track('EntitiesV3.Insert') + async insert(entityName: string, data: EntityV3RecordInput, options?: EntityV3InsertOptions): Promise { + const response = await this.post( + DATA_FABRIC_V3_ENDPOINTS.ENTITY.INSERT(entityName), + data, + { + params: createParams({ expansionLevel: options?.expansionLevel }), + headers: createHeaders({ [FOLDER_KEY]: options?.folderKey }), + } + ); + return response.data; + } + + @track('EntitiesV3.InsertRecords') + async insertRecords(entityName: string, data: EntityV3RecordInput[], options?: EntityV3BatchOptions): Promise { + const response = await this.post( + DATA_FABRIC_V3_ENDPOINTS.ENTITY.INSERT_BATCH(entityName), + data, + { + params: createParams({ expansionLevel: options?.expansionLevel, failOnFirst: options?.failOnFirst }), + headers: createHeaders({ [FOLDER_KEY]: options?.folderKey }), + } + ); + return response.data; + } + + @track('EntitiesV3.InsertBulk') + async insertBulk(entityName: string, data: EntityV3RecordInput[], options?: EntityV3InsertBulkOptions): Promise { + const response = await this.post( + DATA_FABRIC_V3_ENDPOINTS.ENTITY.INSERT_BULK(entityName), + data, + { headers: createHeaders({ [FOLDER_KEY]: options?.folderKey }) } + ); + return response.data; + } + + @track('EntitiesV3.Upsert') + async upsert(entityName: string, data: EntityV3RecordInput, options?: EntityV3InsertOptions): Promise { + const response = await this.post( + DATA_FABRIC_V3_ENDPOINTS.ENTITY.UPSERT(entityName), + data, + { + params: createParams({ expansionLevel: options?.expansionLevel }), + headers: createHeaders({ [FOLDER_KEY]: options?.folderKey }), + } + ); + return response.data; + } + + @track('EntitiesV3.Update') + async update(entityName: string, recordId: string, data: EntityV3RecordInput, options?: EntityV3UpdateOptions): Promise { + const response = await this.post( + DATA_FABRIC_V3_ENDPOINTS.ENTITY.UPDATE(entityName, recordId), + data, + { + params: createParams({ expansionLevel: options?.expansionLevel }), + headers: createHeaders({ [FOLDER_KEY]: options?.folderKey }), + } + ); + return response.data; + } + + @track('EntitiesV3.UpdateByKey') + async updateByKey(entityName: string, key: string, data: EntityV3RecordInput, options?: EntityV3UpdateOptions): Promise { + const response = await this.post( + DATA_FABRIC_V3_ENDPOINTS.ENTITY.UPDATE_BY_KEY(entityName, key), + data, + { + params: createParams({ expansionLevel: options?.expansionLevel }), + headers: createHeaders({ [FOLDER_KEY]: options?.folderKey }), + } + ); + return response.data; + } + + @track('EntitiesV3.UpdateRecords') + async updateRecords(entityName: string, data: EntityV3RecordInput[], options?: EntityV3BatchOptions): Promise { + const response = await this.post( + DATA_FABRIC_V3_ENDPOINTS.ENTITY.UPDATE_BATCH(entityName), + data, + { + params: createParams({ expansionLevel: options?.expansionLevel, failOnFirst: options?.failOnFirst }), + headers: createHeaders({ [FOLDER_KEY]: options?.folderKey }), + } + ); + return response.data; + } + + @track('EntitiesV3.UpdateWhere') + async updateWhere(entityName: string, request: EntityV3ConditionalUpdateRequest, options?: EntityV3UpdateWhereOptions): Promise { + const response = await this.post( + DATA_FABRIC_V3_ENDPOINTS.ENTITY.UPDATE_WHERE(entityName), + request, + { headers: createHeaders({ [FOLDER_KEY]: options?.folderKey }) } + ); + return response.data; + } + + @track('EntitiesV3.DeleteRecord') + async deleteRecord(entityName: string, recordId: string, options?: EntityV3DeleteOptions): Promise { + const response = await this.delete( + DATA_FABRIC_V3_ENDPOINTS.ENTITY.DELETE_RECORD(entityName, recordId), + { headers: createHeaders({ [FOLDER_KEY]: options?.folderKey }) } + ); + return response.data; + } + + @track('EntitiesV3.DeleteRecords') + async deleteRecords(entityName: string, recordIds: string[], options?: EntityV3DeleteOptions): Promise { + const response = await this.post( + DATA_FABRIC_V3_ENDPOINTS.ENTITY.DELETE(entityName), + recordIds, + { headers: createHeaders({ [FOLDER_KEY]: options?.folderKey }) } + ); + return response.data; + } + + @track('EntitiesV3.DeleteRecordsBatch') + async deleteRecordsBatch(entityName: string, recordIds: string[], options?: EntityV3DeleteBatchOptions): Promise { + const response = await this.post( + DATA_FABRIC_V3_ENDPOINTS.ENTITY.DELETE_BATCH(entityName), + recordIds, + { + params: createParams({ failOnFirst: options?.failOnFirst }), + headers: createHeaders({ [FOLDER_KEY]: options?.folderKey }), + } + ); + return response.data; + } + + // ----- Members ------------------------------------------------------------ + + @track('EntitiesV3.QueryMember') + async queryMember( + entityName: string, + memberName: string, + options?: T + ): Promise ? PaginatedResponse> : NonPaginatedResponse>> { + const { folderKey, ...rest } = options ?? {}; + const downstreamOptions = options === undefined ? undefined : (rest as T); + return PaginationHelpers.getAll({ + serviceAccess: this.createPaginationServiceAccess(), + getEndpoint: () => DATA_FABRIC_V3_ENDPOINTS.MEMBER.QUERY(entityName, memberName), + method: HTTP_METHODS.POST, + headers: createHeaders({ [FOLDER_KEY]: folderKey }), + pagination: V3_PAGINATION, + excludeFromPrefix: V3_QUERY_EXCLUDE_FROM_PREFIX, + }, downstreamOptions); + } + + @track('EntitiesV3.GetMemberRecords') + async getMemberRecords( + entityName: string, + memberName: string, + options?: T + ): Promise ? PaginatedResponse> : NonPaginatedResponse>> { + const { folderKey, ...rest } = options ?? {}; + const downstreamOptions = options === undefined ? undefined : (rest as T); + return PaginationHelpers.getAll({ + serviceAccess: this.createPaginationServiceAccess(), + getEndpoint: () => DATA_FABRIC_V3_ENDPOINTS.MEMBER.READ(entityName, memberName), + headers: createHeaders({ [FOLDER_KEY]: folderKey }), + pagination: V3_PAGINATION, + }, downstreamOptions); + } + + @track('EntitiesV3.GetMemberRecord') + async getMemberRecord(entityName: string, memberName: string, recordId: string, options?: EntityV3MemberGetRecordOptions): Promise> { + const response = await this.get>( + DATA_FABRIC_V3_ENDPOINTS.MEMBER.READ_RECORD(entityName, memberName, recordId), + { headers: createHeaders({ [FOLDER_KEY]: options?.folderKey }) } + ); + return response.data; + } + + @track('EntitiesV3.GetMemberRecordByKey') + async getMemberRecordByKey(entityName: string, memberName: string, key: string, options?: EntityV3MemberGetRecordOptions): Promise> { + const response = await this.get>( + DATA_FABRIC_V3_ENDPOINTS.MEMBER.READ_BY_KEY(entityName, memberName, key), + { headers: createHeaders({ [FOLDER_KEY]: options?.folderKey }) } + ); + return response.data; + } + + @track('EntitiesV3.DeleteMemberRecord') + async deleteMemberRecord(entityName: string, memberName: string, recordId: string, options?: EntityV3DeleteOptions): Promise { + const response = await this.delete( + DATA_FABRIC_V3_ENDPOINTS.MEMBER.DELETE_RECORD(entityName, memberName, recordId), + { headers: createHeaders({ [FOLDER_KEY]: options?.folderKey }) } + ); + return response.data; + } + + @track('EntitiesV3.DeleteMemberRecords') + async deleteMemberRecords(entityName: string, memberName: string, recordIds: string[], options?: EntityV3DeleteOptions): Promise { + const response = await this.post( + DATA_FABRIC_V3_ENDPOINTS.MEMBER.DELETE(entityName, memberName), + recordIds, + { headers: createHeaders({ [FOLDER_KEY]: options?.folderKey }) } + ); + return response.data; + } + + @track('EntitiesV3.CreateMemberField') + async createMemberField(entityName: string, memberName: string, request: EntityV3FieldCreateRequest, options?: EntityV3SchemaOptions): Promise { + const response = await this.post( + DATA_FABRIC_V3_ENDPOINTS.MEMBER.FIELD.CREATE(entityName, memberName), + request, + { headers: createHeaders({ [FOLDER_KEY]: options?.folderKey }) } + ); + return response.data; + } + + @track('EntitiesV3.UpdateMemberField') + async updateMemberField(entityName: string, memberName: string, fieldId: string, request: EntityV3FieldUpdateRequest, options?: EntityV3SchemaOptions): Promise { + const response = await this.patch( + DATA_FABRIC_V3_ENDPOINTS.MEMBER.FIELD.UPDATE(entityName, memberName, fieldId), + { id: fieldId, ...request }, + { headers: createHeaders({ [FOLDER_KEY]: options?.folderKey }) } + ); + return response.data; + } + + @track('EntitiesV3.DeleteMemberField') + async deleteMemberField(entityName: string, memberName: string, fieldId: string, options?: EntityV3SchemaOptions): Promise { + const response = await this.delete( + DATA_FABRIC_V3_ENDPOINTS.MEMBER.FIELD.DELETE(entityName, memberName, fieldId), + { headers: createHeaders({ [FOLDER_KEY]: options?.folderKey }) } + ); + return response.data; + } + + @track('EntitiesV3.DeleteMemberFieldHard') + async deleteMemberFieldHard(entityName: string, memberName: string, fieldId: string, options?: EntityV3SchemaOptions): Promise { + const response = await this.post( + DATA_FABRIC_V3_ENDPOINTS.MEMBER.FIELD.DELETE_HARD(entityName, memberName, fieldId), + undefined, + { headers: createHeaders({ [FOLDER_KEY]: options?.folderKey }) } + ); + return response.data; + } + + // ----- Attachments -------------------------------------------------------- + + @track('EntitiesV3.DownloadAttachment') + async downloadAttachment(entityName: string, recordId: string, fieldName: string, options?: EntityV3DownloadAttachmentOptions): Promise { + const response = await this.get( + DATA_FABRIC_V3_ENDPOINTS.ENTITY.ATTACHMENT.DOWNLOAD(entityName, recordId, fieldName), + { + responseType: RESPONSE_TYPES.BLOB, + headers: createHeaders({ [FOLDER_KEY]: options?.folderKey }), + } + ); + return response.data; + } + + @track('EntitiesV3.UploadAttachment') + async uploadAttachment(entityName: string, recordId: string, fieldName: string, file: EntityFileType, options?: EntityV3UploadAttachmentOptions): Promise> { + const formData = new FormData(); + if (file instanceof Uint8Array) { + formData.append('file', new Blob([file.buffer as ArrayBuffer])); + } else { + formData.append('file', file); + } + const response = await this.post>( + DATA_FABRIC_V3_ENDPOINTS.ENTITY.ATTACHMENT.UPLOAD(entityName, recordId, fieldName), + formData, + { + params: createParams({ expansionLevel: options?.expansionLevel }), + headers: createHeaders({ [FOLDER_KEY]: options?.folderKey }), + } + ); + return response.data; + } + + @track('EntitiesV3.DeleteAttachment') + async deleteAttachment(entityName: string, recordId: string, fieldName: string, options?: EntityV3DeleteAttachmentOptions): Promise> { + const response = await this.delete>( + DATA_FABRIC_V3_ENDPOINTS.ENTITY.ATTACHMENT.DELETE(entityName, recordId, fieldName), + { + params: createParams({ expansionLevel: options?.expansionLevel }), + headers: createHeaders({ [FOLDER_KEY]: options?.folderKey }), + } + ); + return response.data; + } + + // ----- Autopilot ---------------------------------------------------------- + + @track('EntitiesV3.ManageWithAutopilot') + async manageWithAutopilot(request: EntityV3AutopilotRequest): Promise { + const response = await this.post( + DATA_FABRIC_V3_ENDPOINTS.ENTITY.AUTOPILOT.MANAGE, + request + ); + return response.data; + } + + @track('EntitiesV3.ManageWithAutopilotStream') + async manageWithAutopilotStream(request: EntityV3AutopilotRequest): Promise> { + const response = await this.post>( + DATA_FABRIC_V3_ENDPOINTS.ENTITY.AUTOPILOT.MANAGE_STREAM, + request, + { responseType: RESPONSE_TYPES.STREAM } + ); + return response.data; + } +} diff --git a/src/services/data-fabric/entities-v3/index.ts b/src/services/data-fabric/entities-v3/index.ts new file mode 100644 index 000000000..3a271bc4a --- /dev/null +++ b/src/services/data-fabric/entities-v3/index.ts @@ -0,0 +1,46 @@ +/** + * Data Fabric Entity v3 Service Module + * + * Provides access to the UiPath Data Fabric Entity **v3** API, which adds + * composite-entity support (entities backed by multiple related "member" tables) + * on top of the v1/v2 surface. + * + * @example + * ```typescript + * import { UiPath } from '@uipath/uipath-typescript/core'; + * import { EntitiesV3 } from '@uipath/uipath-typescript/entities-v3'; + * + * const sdk = new UiPath(config); + * await sdk.initialize(); + * + * const entitiesV3 = new EntitiesV3(sdk); + * const entities = await entitiesV3.getAll(); + * ``` + * + * @module + */ + +// Export service with a cleaner plural alias, plus the class name. +export { EntityV3Service as EntitiesV3, EntityV3Service } from '../entities-v3'; + +// v3-specific types and models +export * from '../../../models/data-fabric/entities-v3.types'; +export * from '../../../models/data-fabric/entities-v3.models'; + +// Shared Data Fabric query/field types referenced by v3 options (re-exported for convenience) +export { + LogicalOperator, + QueryFilterOperator, + EntityFieldDataType, + EntityAggregateFunction, + JoinType, +} from '../../../models/data-fabric/entities.types'; +export type { + EntityQueryFilter, + EntityQueryFilterGroup, + EntityQuerySortOption, + EntityAggregate, + EntityJoin, + SqlType, + EntityFileType, +} from '../../../models/data-fabric/entities.types'; diff --git a/src/utils/constants/endpoints/data-fabric.ts b/src/utils/constants/endpoints/data-fabric.ts index 7ac6e724f..a44b7f37f 100644 --- a/src/utils/constants/endpoints/data-fabric.ts +++ b/src/utils/constants/endpoints/data-fabric.ts @@ -63,3 +63,91 @@ export const DATA_FABRIC_ENDPOINTS = { REVOKE_ROLES: `${DATAFABRIC_BASE}/api/Directory/RevokeRole`, }, } as const; + +/** Base path for all v3 Entity API routes. */ +const V3_BASE = `${DATAFABRIC_BASE}/api/v3/entities`; + +/** + * Data Fabric Entity Service v3 Endpoints. + * + * The v3 API adds composite-entity support (entities backed by multiple related + * "member" tables) on top of the v1/v2 surface. Data operations are addressed by + * entity **name**; schema operations are addressed by entity **id** (GUID). + */ +export const DATA_FABRIC_V3_ENDPOINTS = { + ENTITY: { + // Schema / listing + LIST: V3_BASE, + LIST_ALL: `${V3_BASE}/all`, + FOLDER_ENTITIES: `${V3_BASE}/folder-entities`, + CREATE: V3_BASE, + GET_BY_ID: (entityId: string) => `${V3_BASE}/${entityId}`, + DELETE_BY_ID: (entityId: string) => `${V3_BASE}/${entityId}`, + GET_METADATA: (entityName: string) => `${V3_BASE}/${entityName}/metadata`, + UPDATE_METADATA: (entityId: string) => `${V3_BASE}/${entityId}/metadata`, + + // Data (by entity name) + QUERY: (entityName: string) => `${V3_BASE}/${entityName}/query`, + QUERY_EXPANSION: (entityName: string) => `${V3_BASE}/${entityName}/query_expansion`, + READ: (entityName: string) => `${V3_BASE}/${entityName}/read`, + READ_RECORD: (entityName: string, recordId: string) => `${V3_BASE}/${entityName}/read/${recordId}`, + READ_BY_KEY: (entityName: string, key: string) => `${V3_BASE}/${entityName}/readByKey/${key}`, + INSERT: (entityName: string) => `${V3_BASE}/${entityName}/insert`, + INSERT_BATCH: (entityName: string) => `${V3_BASE}/${entityName}/insert-batch`, + INSERT_BULK: (entityName: string) => `${V3_BASE}/${entityName}/insert_bulk`, + UPSERT: (entityName: string) => `${V3_BASE}/${entityName}/upsert`, + UPDATE: (entityName: string, recordId: string) => `${V3_BASE}/${entityName}/update/${recordId}`, + UPDATE_BY_KEY: (entityName: string, key: string) => `${V3_BASE}/${entityName}/updateByKey/${key}`, + UPDATE_BATCH: (entityName: string) => `${V3_BASE}/${entityName}/update-batch`, + UPDATE_WHERE: (entityName: string) => `${V3_BASE}/${entityName}/update-where`, + DELETE_RECORD: (entityName: string, recordId: string) => `${V3_BASE}/${entityName}/delete/${recordId}`, + DELETE: (entityName: string) => `${V3_BASE}/${entityName}/delete`, + DELETE_BATCH: (entityName: string) => `${V3_BASE}/${entityName}/delete-batch`, + + // Entity field schema (by entity id) + FIELD: { + CREATE: (entityId: string) => `${V3_BASE}/${entityId}/field`, + UPDATE: (entityId: string, fieldId: string) => `${V3_BASE}/${entityId}/field/${fieldId}`, + DELETE: (entityId: string, fieldId: string) => `${V3_BASE}/${entityId}/field/${fieldId}`, + }, + + // Attachments (by entity name) + ATTACHMENT: { + DOWNLOAD: (entityName: string, recordId: string, fieldName: string) => + `${V3_BASE}/${entityName}/records/${recordId}/attachments/${fieldName}`, + UPLOAD: (entityName: string, recordId: string, fieldName: string) => + `${V3_BASE}/${entityName}/records/${recordId}/attachments/${fieldName}`, + DELETE: (entityName: string, recordId: string, fieldName: string) => + `${V3_BASE}/${entityName}/records/${recordId}/attachments/${fieldName}`, + }, + + // Autopilot + AUTOPILOT: { + MANAGE: `${V3_BASE}/autopilot/manage`, + MANAGE_STREAM: `${V3_BASE}/autopilot/manage/stream`, + }, + }, + MEMBER: { + // Data (sub-resource of a composite entity, by member instance name) + QUERY: (compositeName: string, memberName: string) => `${V3_BASE}/${compositeName}/members/${memberName}/query`, + READ: (compositeName: string, memberName: string) => `${V3_BASE}/${compositeName}/members/${memberName}/read`, + READ_RECORD: (compositeName: string, memberName: string, recordId: string) => + `${V3_BASE}/${compositeName}/members/${memberName}/read/${recordId}`, + READ_BY_KEY: (compositeName: string, memberName: string, key: string) => + `${V3_BASE}/${compositeName}/members/${memberName}/readByKey/${key}`, + DELETE_RECORD: (compositeName: string, memberName: string, recordId: string) => + `${V3_BASE}/${compositeName}/members/${memberName}/delete/${recordId}`, + DELETE: (compositeName: string, memberName: string) => `${V3_BASE}/${compositeName}/members/${memberName}/delete`, + + // Member field schema + FIELD: { + CREATE: (compositeName: string, memberName: string) => `${V3_BASE}/${compositeName}/members/${memberName}/field`, + UPDATE: (compositeName: string, memberName: string, fieldId: string) => + `${V3_BASE}/${compositeName}/members/${memberName}/field/${fieldId}`, + DELETE_HARD: (compositeName: string, memberName: string, fieldId: string) => + `${V3_BASE}/${compositeName}/members/${memberName}/field/${fieldId}/delete`, + DELETE: (compositeName: string, memberName: string, fieldId: string) => + `${V3_BASE}/${compositeName}/members/${memberName}/field/${fieldId}`, + }, + }, +} as const; diff --git a/src/utils/constants/headers.ts b/src/utils/constants/headers.ts index afc2a43cb..0cff8f2ca 100644 --- a/src/utils/constants/headers.ts +++ b/src/utils/constants/headers.ts @@ -26,7 +26,9 @@ export const RESPONSE_TYPES = { JSON: 'json', TEXT: 'text', BLOB: 'blob', - ARRAYBUFFER: 'arraybuffer' + ARRAYBUFFER: 'arraybuffer', + /** Returns the raw `ReadableStream` response body without buffering — used for server-sent-event / chunked endpoints. */ + STREAM: 'stream' } as const; /** diff --git a/tests/integration/config/unified-setup.ts b/tests/integration/config/unified-setup.ts index a33c45385..5fae7ca19 100644 --- a/tests/integration/config/unified-setup.ts +++ b/tests/integration/config/unified-setup.ts @@ -5,6 +5,7 @@ import { DataFabricRoleService, Entities, } from '../../../src/services/data-fabric'; +import { EntityV3Service } from '../../../src/services/data-fabric/entities-v3'; import { Tasks } from '../../../src/services/action-center'; import { Assets, Buckets, Jobs, Queues, Processes } from '../../../src/services/orchestrator'; import { AttachmentService as Attachments } from '../../../src/services/orchestrator/attachments'; @@ -43,6 +44,7 @@ export { export interface TestServices { sdk: UiPath; entities: Entities; + entitiesV3?: EntityV3Service; choiceSets: ChoiceSets; dataFabricRoles: DataFabricRoleService; dataFabricDirectory: DataFabricDirectoryService; @@ -133,6 +135,7 @@ function createV1Services(config: IntegrationConfig): TestServices { return { sdk, entities: new Entities(sdk), + entitiesV3: new EntityV3Service(sdk), choiceSets: new ChoiceSets(sdk), dataFabricRoles: new DataFabricRoleService(sdk), dataFabricDirectory: new DataFabricDirectoryService(sdk), diff --git a/tests/integration/shared/data-fabric/entities-v3.integration.test.ts b/tests/integration/shared/data-fabric/entities-v3.integration.test.ts new file mode 100644 index 000000000..e7a2cb66d --- /dev/null +++ b/tests/integration/shared/data-fabric/entities-v3.integration.test.ts @@ -0,0 +1,505 @@ +import { describe, it, expect, afterAll, beforeAll } from 'vitest'; +import { + getServices, + getTestConfig, + setupUnifiedTests, + cleanupTestEntityRecords, + InitMode, +} from '../../config/unified-setup'; +import { registerResource } from '../../utils/cleanup'; +import { + generateRandomString, + generateRandomInt, + generateRandomFloat, + hasValidPagination, +} from '../../utils/helpers'; +import { EntityV3Service } from '../../../../src/services/data-fabric/entities-v3'; +import { EntityV3CreateRequest } from '../../../../src/models/data-fabric/entities-v3.types'; +import { + EntityFieldDataType, + FieldDisplayType, + FieldMetaData, + RawEntityGetResponse, +} from '../../../../src/models/data-fabric/entities.types'; +import { DATA_FABRIC_TENANT_FOLDER_ID } from '../../../../src/utils/constants/endpoints/data-fabric'; + +// Cache for choice set values to avoid repeated API calls within a test run +const choiceSetValueCache = new Map(); + +/** + * Fetches and caches choice set values for a given choice set ID. + * When the target choice set lives in a folder (not tenant-level), pass that + * folder's key so the lookup carries the X-UIPATH-FolderKey header — otherwise + * the server returns empty values and required CS fields end up undefined. + */ +async function getChoiceSetValues(choiceSetId: string, folderKey?: string): Promise { + const cacheKey = `${folderKey ?? ''}::${choiceSetId}`; + if (choiceSetValueCache.has(cacheKey)) { + return choiceSetValueCache.get(cacheKey)!; + } + const { choiceSets } = getServices(); + const result = await choiceSets.getById(choiceSetId, folderKey ? { folderKey } : undefined); + const values = result.items || []; + choiceSetValueCache.set(cacheKey, values); + return values; +} + +/** + * Generates a dummy value for a given entity field based on its data type. + * Handles all EntityFieldDataType values so tests work regardless of entity schema. + */ +function generateFieldValue(field: FieldMetaData): any { + const { fieldDataType } = field; + + if (!fieldDataType) return `Test_${generateRandomString(6)}`; + + switch (fieldDataType.name) { + case EntityFieldDataType.STRING: + return `Test_${generateRandomString(8)}`; + case EntityFieldDataType.MULTILINE_TEXT: + return `Test multiline\n${generateRandomString(12)}`; + case EntityFieldDataType.MULTILINE_MAX: + return `Test multiline max\n${generateRandomString(64)}`; + case EntityFieldDataType.INTEGER: { + const max = fieldDataType.maxValue ?? 10000; + const min = fieldDataType.minValue ?? 0; + return generateRandomInt(min, max); + } + case EntityFieldDataType.BIG_INTEGER: { + const max = fieldDataType.maxValue ?? 100000; + const min = fieldDataType.minValue ?? 0; + return generateRandomInt(min, max); + } + case EntityFieldDataType.FLOAT: + case EntityFieldDataType.DOUBLE: { + const max = fieldDataType.maxValue ?? 1000; + const min = fieldDataType.minValue ?? 0; + return generateRandomFloat(min, max); + } + case EntityFieldDataType.DECIMAL: { + const precision = fieldDataType.decimalPrecision ?? 2; + const max = fieldDataType.maxValue ?? 1000; + const min = fieldDataType.minValue ?? 0; + return generateRandomFloat(min, max, precision); + } + case EntityFieldDataType.BOOLEAN: + return true; + case EntityFieldDataType.DATE: + return new Date().toISOString().split('T')[0]; + case EntityFieldDataType.DATETIME: + case EntityFieldDataType.DATETIME_WITH_TZ: + return new Date().toISOString(); + case EntityFieldDataType.UUID: + return undefined; // UUIDs are typically auto-generated + default: + return `Test_${generateRandomString(6)}`; + } +} + +/** + * Returns only the fields that are safe to write to when inserting a record. + * Filters out system fields, primary keys, auto-numbers, relationships, and UUIDs. + * File fields are excluded because they require a separate upload API. + */ +function getWritableFields(fields: FieldMetaData[]): FieldMetaData[] { + return fields.filter( + (f) => + !f.isSystemField && + !f.isPrimaryKey && + !f.isHiddenField && + f.fieldDisplayType !== FieldDisplayType.AutoNumber && + f.fieldDisplayType !== FieldDisplayType.Relationship && + f.fieldDisplayType !== FieldDisplayType.File && + f.fieldDataType?.name !== EntityFieldDataType.UUID + ); +} + +/** + * Builds a dummy record object that conforms to the entity's schema. + * Discovers the schema dynamically and generates appropriate values, including + * looking up valid choice set values for ChoiceSetSingle/ChoiceSetMultiple fields. + */ +async function buildDummyRecord(entityMetadata: RawEntityGetResponse): Promise> { + const writableFields = getWritableFields(entityMetadata.fields); + const record: Record = {}; + + for (const field of writableFields) { + if ( + field.fieldDisplayType === FieldDisplayType.ChoiceSetSingle || + field.fieldDisplayType === FieldDisplayType.ChoiceSetMultiple + ) { + const choiceSetId = field.choiceSetId || field.referenceChoiceSet?.id; + if (!choiceSetId) continue; + + const csFolderId = field.referenceChoiceSet?.folderId; + const csFolderKey = csFolderId && csFolderId !== DATA_FABRIC_TENANT_FOLDER_ID ? csFolderId : undefined; + const values = await getChoiceSetValues(choiceSetId, csFolderKey); + if (values.length === 0) continue; + + if (field.fieldDisplayType === FieldDisplayType.ChoiceSetSingle) { + record[field.name] = values[0].numberId; + } else { + record[field.name] = [values[0].numberId]; + } + } else { + const value = generateFieldValue(field); + if (value !== undefined) { + record[field.name] = value; + } + } + } + + return record; +} + +// entitiesV3 is only registered in v1 mode — v0 (legacy SDK) does not expose it. +const modes: InitMode[] = ['v1']; + +describe.each(modes)('Data Fabric Entities v3 - Integration Tests [%s]', (mode) => { + setupUnifiedTests(mode); + + let entitiesV3!: EntityV3Service; + // A known-good entity used for the read/list surface (addressed by id and name). + let readEntityId!: string; + let readEntityName!: string; + + beforeAll(async () => { + const services = getServices(); + if (!services.entitiesV3) { + throw new Error( + `entitiesV3 service is not available in "${mode}" init mode — it is registered in v1 mode only` + ); + } + entitiesV3 = services.entitiesV3; + + const config = getTestConfig(); + const list = await entitiesV3.getAll(); + if (list.length === 0) { + throw new Error('No Data Fabric entities available in the test tenant — cannot run v3 read tests'); + } + + readEntityId = config.dataFabricTestEntityId ?? list[0].id; + const meta = await entitiesV3.getById(readEntityId); + readEntityName = meta.name; + }); + + describe('getAll', () => { + it('should list entities in the tenant', async () => { + const result = await entitiesV3.getAll(); + + expect(result).toBeDefined(); + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBeGreaterThan(0); + }); + + it('should return entities with valid metadata structure', async () => { + const result = await entitiesV3.getAll(); + + const entity = result[0]; + expect(typeof entity.id).toBe('string'); + expect(typeof entity.name).toBe('string'); + expect(typeof entity.displayName).toBe('string'); + }); + + it('should filter the listing by entityClass', async () => { + // entityClass narrows the listing server-side; the result is still a valid + // (possibly empty) array of entity metadata. + const result = await entitiesV3.getAll({ entityClass: 'CaseComposite' }); + + expect(Array.isArray(result)).toBe(true); + for (const entity of result) { + expect(typeof entity.id).toBe('string'); + } + }); + }); + + describe('getAllWithChoiceSets', () => { + it('should list entities together with choice sets using a start/limit window', async () => { + const result = await entitiesV3.getAllWithChoiceSets({ start: 0, limit: 50 }); + + expect(result).toBeDefined(); + if (result.entities) { + expect(Array.isArray(result.entities)).toBe(true); + } + if (result.choicesets) { + expect(Array.isArray(result.choicesets)).toBe(true); + } + }); + }); + + describe('getFolderEntities', () => { + it('should list tenant-level and folder-level entities together', async () => { + const result = await entitiesV3.getFolderEntities(); + + expect(result).toBeDefined(); + expect(Array.isArray(result)).toBe(true); + }); + }); + + describe('getById', () => { + it('should retrieve entity metadata by ID with operation methods attached', async () => { + const result = await entitiesV3.getById(readEntityId); + + expect(result).toBeDefined(); + expect(result.id).toBe(readEntityId); + expect(typeof result.name).toBe('string'); + + // Bound operation methods must be attached to the response. + expect(typeof result.getRecords).toBe('function'); + expect(typeof result.getRecord).toBe('function'); + expect(typeof result.query).toBe('function'); + expect(typeof result.insert).toBe('function'); + expect(typeof result.update).toBe('function'); + expect(typeof result.deleteRecord).toBe('function'); + expect(typeof result.delete).toBe('function'); + }); + }); + + describe('getMetadata', () => { + it('should retrieve entity metadata by name with operation methods attached', async () => { + const result = await entitiesV3.getMetadata(readEntityName); + + expect(result).toBeDefined(); + expect(result.name).toBe(readEntityName); + expect(typeof result.getRecords).toBe('function'); + expect(typeof result.insert).toBe('function'); + }); + }); + + describe('getRecords', () => { + it('should retrieve a page of records addressed by entity name', async () => { + const result = await entitiesV3.getRecords(readEntityName); + + expect(result).toBeDefined(); + expect(Array.isArray(result.items)).toBe(true); + }); + + it('should return a paginated response when pageSize is provided', async () => { + const result = await entitiesV3.getRecords(readEntityName, { pageSize: 5 }); + + expect(result).toBeDefined(); + expect(Array.isArray(result.items)).toBe(true); + expect(result.items.length).toBeLessThanOrEqual(5); + expect(hasValidPagination(result)).toBe(true); + }); + + // Data Fabric record fields are user-defined schema columns and must be + // returned exactly as stored — no camelCase renaming. The system Id field + // is PascalCase `Id`. + it('should return records with field names exactly as stored', async () => { + const result = await entitiesV3.getRecords(readEntityName, { pageSize: 1 }); + + if (result.items.length === 0) { + throw new Error( + `Entity "${readEntityName}" has no records — cannot verify field-name preservation. ` + + 'Set DATA_FABRIC_TEST_ENTITY_ID to an entity with at least one record.' + ); + } + + const record = result.items[0]; + expect(Object.prototype.hasOwnProperty.call(record, 'Id')).toBe(true); + expect(typeof record.Id).toBe('string'); + }); + }); + + describe('query', () => { + it('should query records with no filters', async () => { + const result = await entitiesV3.query(readEntityName); + + expect(result).toBeDefined(); + expect(Array.isArray(result.items)).toBe(true); + expect(typeof result.totalCount).toBe('number'); + }); + + it('should return a paginated response when pageSize is provided', async () => { + const result = await entitiesV3.query(readEntityName, { pageSize: 2 }); + + expect(result).toBeDefined(); + expect(Array.isArray(result.items)).toBe(true); + expect(result.items.length).toBeLessThanOrEqual(2); + expect(hasValidPagination(result)).toBe(true); + }); + }); + + // Data-plane record lifecycle against an existing entity — runnable under PAT + // (no schema-write scope required). Builds a schema-conformant record via the + // typed v1 metadata, then exercises the v3 write surface addressed by name. + describe('record lifecycle (insert -> read -> update -> delete)', () => { + let lifecycleEntityId!: string; + let lifecycleEntityName!: string; + let lifecycleEntityMeta!: RawEntityGetResponse; + let insertedRecordId: string | undefined; + const createdRecordIds: string[] = []; + + beforeAll(async () => { + const config = getTestConfig(); + lifecycleEntityId = config.dataFabricTestEntityId ?? readEntityId; + if (!lifecycleEntityId) { + throw new Error('No entity ID available for the v3 record lifecycle test. Set DATA_FABRIC_TEST_ENTITY_ID.'); + } + // The typed v1 metadata gives strongly-typed fields for building a dummy record. + const { entities } = getServices(); + lifecycleEntityMeta = await entities.getById(lifecycleEntityId); + lifecycleEntityName = lifecycleEntityMeta.name; + }); + + afterAll(async () => { + if (createdRecordIds.length > 0) { + await cleanupTestEntityRecords(lifecycleEntityId, createdRecordIds); + } + }); + + it('should insert a single record via insert', async () => { + const data = await buildDummyRecord(lifecycleEntityMeta); + const result = await entitiesV3.insert(lifecycleEntityName, data); + + expect(result).toBeDefined(); + expect(typeof result.Id).toBe('string'); + + insertedRecordId = result.Id; + createdRecordIds.push(result.Id); + registerResource('entityRecords', { + entityId: lifecycleEntityId, + recordIds: [result.Id], + }); + }); + + it('should read the inserted record back via getRecord with field names unchanged', async () => { + if (!insertedRecordId) { + throw new Error('No inserted record available to read back'); + } + + const record = await entitiesV3.getRecord(lifecycleEntityName, insertedRecordId); + + expect(record).toBeDefined(); + expect(Object.prototype.hasOwnProperty.call(record, 'Id')).toBe(true); + expect(record.Id).toBe(insertedRecordId); + }); + + it('should update the inserted record via update', async () => { + if (!insertedRecordId) { + throw new Error('No inserted record available to update'); + } + + const writableFields = getWritableFields(lifecycleEntityMeta.fields); + if (writableFields.length === 0) { + throw new Error(`Entity "${lifecycleEntityName}" has no writable fields to update`); + } + + const field = writableFields[0]; + const result = await entitiesV3.update(lifecycleEntityName, insertedRecordId, { + [field.name]: generateFieldValue(field), + }); + + expect(result).toBeDefined(); + expect(result.Id).toBe(insertedRecordId); + }); + + it('should delete the inserted record via deleteRecord', async () => { + if (!insertedRecordId) { + throw new Error('No inserted record available to delete'); + } + + const result = await entitiesV3.deleteRecord(lifecycleEntityName, insertedRecordId); + + expect(result).toBeDefined(); + + // The record is gone — drop it from the cleanup list so afterAll does not + // attempt to delete an already-deleted record. + const idx = createdRecordIds.indexOf(insertedRecordId); + if (idx !== -1) { + createdRecordIds.splice(idx, 1); + } + insertedRecordId = undefined; + }); + }); + + // Schema-write operations (create/delete entity, field CRUD, composite members) + // require the DataFabric.Schema.Write OAuth scope, which PAT tokens cannot hold + // (insufficient_scope). This mirrors the skipped schema-write blocks in + // entities.integration.test.ts. The body is written as it will run once the scope + // is available: create a temporary entity, exercise the write surface, then tear + // the entity down. + describe.skip('entity schema + record lifecycle (requires DataFabric.Schema.Write scope)', () => { + const createdEntityIds: string[] = []; + const createdRecordIds: Array<{ entityId: string; recordId: string }> = []; + + afterAll(async () => { + // Records first (cascade-safe), then the entities themselves. + for (const { entityId, recordId } of createdRecordIds) { + await entitiesV3.deleteRecord(entityId, recordId).catch(() => undefined); + } + for (const entityId of createdEntityIds) { + await entitiesV3.deleteById(entityId).catch(() => undefined); + } + }); + + it('should create an entity, insert/read/update/delete a record, then delete the entity', async () => { + const name = `sdk_v3_${generateRandomString(8).toLowerCase()}`; + const request: EntityV3CreateRequest = { + displayName: `SDK V3 Test ${name}`, + entityDefinition: { + name, + fields: [ + { name: 'Title', isRequired: true, fieldDataType: { name: EntityFieldDataType.STRING } }, + { name: 'Count', fieldDataType: { name: EntityFieldDataType.INTEGER } }, + ], + }, + }; + + const created = await entitiesV3.create(request); + const entityId = typeof created === 'string' ? created : created.entityId; + expect(typeof entityId).toBe('string'); + createdEntityIds.push(entityId); + + // Add a field through the schema-write surface. + const fieldId = await entitiesV3.createField(entityId, { + fieldDefinition: { name: 'Notes', fieldDataType: { name: EntityFieldDataType.STRING } }, + }); + expect(typeof fieldId).toBe('string'); + + // Insert a record addressed by name. + const inserted = await entitiesV3.insert(name, { + Title: `Row_${generateRandomString(6)}`, + Count: generateRandomInt(0, 1000), + }); + expect(typeof inserted.Id).toBe('string'); + createdRecordIds.push({ entityId: name, recordId: inserted.Id }); + + // Read it back with field names preserved. + const record = await entitiesV3.getRecord(name, inserted.Id); + expect(record.Id).toBe(inserted.Id); + + // Update it. + const updated = await entitiesV3.update(name, inserted.Id, { Title: 'Updated' }); + expect(updated.Id).toBe(inserted.Id); + + // Delete the record, then remove it from the cleanup list. + await entitiesV3.deleteRecord(name, inserted.Id); + const idx = createdRecordIds.findIndex((r) => r.recordId === inserted.Id); + if (idx !== -1) { + createdRecordIds.splice(idx, 1); + } + + // Tear the entity down. + await entitiesV3.deleteById(entityId); + const eidx = createdEntityIds.indexOf(entityId); + if (eidx !== -1) { + createdEntityIds.splice(eidx, 1); + } + }); + }); + + // Autopilot requires the AI backend and an OAuth token; PAT tokens cannot reach it. + // Written to run once those prerequisites are in place. + describe.skip('autopilot (requires AI backend + OAuth)', () => { + it('should return an autopilot action for a natural-language instruction', async () => { + const result = await entitiesV3.manageWithAutopilot({ + query: 'Create a Customers entity with a name field', + }); + + expect(result).toBeDefined(); + expect(typeof result.isSuccess).toBe('boolean'); + }); + }); +}); diff --git a/tests/unit/models/data-fabric/entities-v3.test.ts b/tests/unit/models/data-fabric/entities-v3.test.ts new file mode 100644 index 000000000..c9e5df264 --- /dev/null +++ b/tests/unit/models/data-fabric/entities-v3.test.ts @@ -0,0 +1,754 @@ +// ===== IMPORTS ===== +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { createEntityV3WithMethods } from "../../../../src/models/data-fabric/entities-v3.models"; +import type { EntityV3ServiceModel } from "../../../../src/models/data-fabric/entities-v3.models"; +import type { + EntityV3Metadata, + EntityV3RecordInput, + EntityV3ConditionalUpdateRequest, + EntityV3UpdateMetadataRequest, + EntityV3FieldCreateRequest, + EntityV3FieldUpdateRequest, + EntityV3ReadOptions, + EntityV3QueryOptions, + EntityV3InsertOptions, + EntityV3SchemaOptions, +} from "../../../../src/models/data-fabric/entities-v3.types"; + +// ===== TEST CONSTANTS ===== +const ENTITY_ID = "11111111-1111-1111-1111-111111111111"; +const ENTITY_NAME = "LoanCaseForBank"; +const ENTITY_DISPLAY_NAME = "Loan Case For Bank"; +const RECORD_ID = "22222222-2222-2222-2222-222222222222"; +const RECORD_ID_2 = "33333333-3333-3333-3333-333333333333"; +const RECORD_KEY = "CASE-001"; +const MEMBER_NAME = "Comments"; +const FIELD_ID = "44444444-4444-4444-4444-444444444444"; +const FIELD_NAME = "Avatar"; +const FOLDER_KEY = "55555555-5555-5555-5555-555555555555"; + +const ERROR_NAME_UNDEFINED = "Entity name is undefined"; +const ERROR_ID_UNDEFINED = "Entity ID is undefined"; +const ERROR_MEMBER_UNDEFINED = "Member name is undefined"; +const ERROR_RECORD_ID_UNDEFINED = "Record ID is undefined"; +const ERROR_RECORD_KEY_UNDEFINED = "Record key is undefined"; +const ERROR_FIELD_ID_UNDEFINED = "Field ID is undefined"; + +const RECORD_INPUT: EntityV3RecordInput = { CaseId: "CASE-001", CaseStatus: "Open" }; +const RECORD_INPUT_2: EntityV3RecordInput = { CaseId: "CASE-002", CaseStatus: "Closed" }; +const SCHEMA_OPTIONS: EntityV3SchemaOptions = { folderKey: FOLDER_KEY }; +const READ_OPTIONS: EntityV3ReadOptions = { expansionLevel: 1 }; +const QUERY_OPTIONS: EntityV3QueryOptions = { selectedFields: ["CaseId"] }; +const INSERT_OPTIONS: EntityV3InsertOptions = { expansionLevel: 1 }; +const CONDITIONAL_UPDATE_REQUEST: EntityV3ConditionalUpdateRequest = { + fieldValues: { CaseStatus: "Closed" }, +}; +const UPDATE_METADATA_REQUEST: EntityV3UpdateMetadataRequest = { displayName: "Renamed" }; +const FIELD_CREATE_REQUEST: EntityV3FieldCreateRequest = { + fieldDefinition: { name: "Notes" }, +}; +const FIELD_UPDATE_REQUEST: EntityV3FieldUpdateRequest = { displayName: "Internal Notes" }; + +// A sentinel value used to prove the service's return value is passed through unchanged. +const SENTINEL = { sentinel: true }; + +/** Builds entity metadata, allowing individual fields to be overridden or omitted. */ +const makeEntity = (overrides: Partial = {}): EntityV3Metadata => ({ + id: ENTITY_ID, + name: ENTITY_NAME, + displayName: ENTITY_DISPLAY_NAME, + ...overrides, +}); + +// ===== TEST SUITE ===== +describe("Entity V3 Models", () => { + let mockService: EntityV3ServiceModel; + + beforeEach(() => { + mockService = { + // Service-level entry points (not bound, but required to satisfy the type) + getAll: vi.fn(), + getAllWithChoiceSets: vi.fn(), + getFolderEntities: vi.fn(), + getById: vi.fn(), + getMetadata: vi.fn(), + create: vi.fn(), + deleteById: vi.fn(), + updateMetadata: vi.fn(), + createField: vi.fn(), + updateField: vi.fn(), + deleteField: vi.fn(), + manageWithAutopilot: vi.fn(), + manageWithAutopilotStream: vi.fn(), + // Data operations + getRecords: vi.fn(), + getRecord: vi.fn(), + getRecordByKey: vi.fn(), + query: vi.fn(), + queryWithExpansion: vi.fn(), + insert: vi.fn(), + insertRecords: vi.fn(), + insertBulk: vi.fn(), + upsert: vi.fn(), + update: vi.fn(), + updateByKey: vi.fn(), + updateRecords: vi.fn(), + updateWhere: vi.fn(), + deleteRecord: vi.fn(), + deleteRecords: vi.fn(), + deleteRecordsBatch: vi.fn(), + queryMember: vi.fn(), + getMemberRecords: vi.fn(), + getMemberRecord: vi.fn(), + getMemberRecordByKey: vi.fn(), + deleteMemberRecord: vi.fn(), + deleteMemberRecords: vi.fn(), + createMemberField: vi.fn(), + updateMemberField: vi.fn(), + deleteMemberField: vi.fn(), + deleteMemberFieldHard: vi.fn(), + downloadAttachment: vi.fn(), + uploadAttachment: vi.fn(), + deleteAttachment: vi.fn(), + } as EntityV3ServiceModel; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + // ------------------------------------------------------------------------- + // Data-operation bound methods — addressed by the entity's NAME + // ------------------------------------------------------------------------- + describe("data-operation bound methods (addressed by entity name)", () => { + describe("entity.getRecords()", () => { + it("should delegate to service.getRecords with entity name and forward options", async () => { + const entity = createEntityV3WithMethods(makeEntity(), mockService); + mockService.getRecords = vi.fn().mockResolvedValue(SENTINEL); + + const result = await entity.getRecords(READ_OPTIONS); + + expect(mockService.getRecords).toHaveBeenCalledWith(ENTITY_NAME, READ_OPTIONS); + expect(result).toBe(SENTINEL); + }); + }); + + describe("entity.getRecord()", () => { + it("should delegate to service.getRecord with entity name, recordId, and options", async () => { + const entity = createEntityV3WithMethods(makeEntity(), mockService); + mockService.getRecord = vi.fn().mockResolvedValue(SENTINEL); + + const result = await entity.getRecord(RECORD_ID, READ_OPTIONS); + + expect(mockService.getRecord).toHaveBeenCalledWith(ENTITY_NAME, RECORD_ID, READ_OPTIONS); + expect(result).toBe(SENTINEL); + }); + + it("should throw if recordId is missing", async () => { + const entity = createEntityV3WithMethods(makeEntity(), mockService); + await expect(entity.getRecord("")).rejects.toThrow(ERROR_RECORD_ID_UNDEFINED); + }); + }); + + describe("entity.getRecordByKey()", () => { + it("should delegate to service.getRecordByKey with entity name, key, and options", async () => { + const entity = createEntityV3WithMethods(makeEntity(), mockService); + mockService.getRecordByKey = vi.fn().mockResolvedValue(SENTINEL); + + const result = await entity.getRecordByKey(RECORD_KEY, READ_OPTIONS); + + expect(mockService.getRecordByKey).toHaveBeenCalledWith(ENTITY_NAME, RECORD_KEY, READ_OPTIONS); + expect(result).toBe(SENTINEL); + }); + + it("should throw if key is missing", async () => { + const entity = createEntityV3WithMethods(makeEntity(), mockService); + await expect(entity.getRecordByKey("")).rejects.toThrow(ERROR_RECORD_KEY_UNDEFINED); + }); + }); + + describe("entity.query()", () => { + it("should delegate to service.query with entity name and forward options", async () => { + const entity = createEntityV3WithMethods(makeEntity(), mockService); + mockService.query = vi.fn().mockResolvedValue(SENTINEL); + + const result = await entity.query(QUERY_OPTIONS); + + expect(mockService.query).toHaveBeenCalledWith(ENTITY_NAME, QUERY_OPTIONS); + expect(result).toBe(SENTINEL); + }); + }); + + describe("entity.queryWithExpansion()", () => { + it("should delegate to service.queryWithExpansion with entity name and forward options", async () => { + const entity = createEntityV3WithMethods(makeEntity(), mockService); + mockService.queryWithExpansion = vi.fn().mockResolvedValue(SENTINEL); + + const result = await entity.queryWithExpansion(QUERY_OPTIONS); + + expect(mockService.queryWithExpansion).toHaveBeenCalledWith(ENTITY_NAME, QUERY_OPTIONS); + expect(result).toBe(SENTINEL); + }); + }); + + describe("entity.insert()", () => { + it("should delegate to service.insert with entity name, data, and options", async () => { + const entity = createEntityV3WithMethods(makeEntity(), mockService); + mockService.insert = vi.fn().mockResolvedValue(SENTINEL); + + const result = await entity.insert(RECORD_INPUT, INSERT_OPTIONS); + + expect(mockService.insert).toHaveBeenCalledWith(ENTITY_NAME, RECORD_INPUT, INSERT_OPTIONS); + expect(result).toBe(SENTINEL); + }); + }); + + describe("entity.insertRecords()", () => { + it("should delegate to service.insertRecords with entity name and data array", async () => { + const entity = createEntityV3WithMethods(makeEntity(), mockService); + mockService.insertRecords = vi.fn().mockResolvedValue(SENTINEL); + + const data = [RECORD_INPUT, RECORD_INPUT_2]; + const result = await entity.insertRecords(data); + + expect(mockService.insertRecords).toHaveBeenCalledWith(ENTITY_NAME, data, undefined); + expect(result).toBe(SENTINEL); + }); + }); + + describe("entity.insertBulk()", () => { + it("should delegate to service.insertBulk with entity name and data array", async () => { + const entity = createEntityV3WithMethods(makeEntity(), mockService); + mockService.insertBulk = vi.fn().mockResolvedValue(true); + + const data = [RECORD_INPUT, RECORD_INPUT_2]; + const result = await entity.insertBulk(data); + + expect(mockService.insertBulk).toHaveBeenCalledWith(ENTITY_NAME, data, undefined); + expect(result).toBe(true); + }); + }); + + describe("entity.upsert()", () => { + it("should delegate to service.upsert with entity name, data, and options", async () => { + const entity = createEntityV3WithMethods(makeEntity(), mockService); + mockService.upsert = vi.fn().mockResolvedValue(SENTINEL); + + const result = await entity.upsert(RECORD_INPUT, INSERT_OPTIONS); + + expect(mockService.upsert).toHaveBeenCalledWith(ENTITY_NAME, RECORD_INPUT, INSERT_OPTIONS); + expect(result).toBe(SENTINEL); + }); + }); + + describe("entity.update()", () => { + it("should delegate to service.update with entity name, recordId, and data", async () => { + const entity = createEntityV3WithMethods(makeEntity(), mockService); + mockService.update = vi.fn().mockResolvedValue(SENTINEL); + + const result = await entity.update(RECORD_ID, RECORD_INPUT); + + expect(mockService.update).toHaveBeenCalledWith(ENTITY_NAME, RECORD_ID, RECORD_INPUT, undefined); + expect(result).toBe(SENTINEL); + }); + + it("should throw if recordId is missing", async () => { + const entity = createEntityV3WithMethods(makeEntity(), mockService); + await expect(entity.update("", RECORD_INPUT)).rejects.toThrow(ERROR_RECORD_ID_UNDEFINED); + }); + }); + + describe("entity.updateByKey()", () => { + it("should delegate to service.updateByKey with entity name, key, and data", async () => { + const entity = createEntityV3WithMethods(makeEntity(), mockService); + mockService.updateByKey = vi.fn().mockResolvedValue(SENTINEL); + + const result = await entity.updateByKey(RECORD_KEY, RECORD_INPUT); + + expect(mockService.updateByKey).toHaveBeenCalledWith(ENTITY_NAME, RECORD_KEY, RECORD_INPUT, undefined); + expect(result).toBe(SENTINEL); + }); + + it("should throw if key is missing", async () => { + const entity = createEntityV3WithMethods(makeEntity(), mockService); + await expect(entity.updateByKey("", RECORD_INPUT)).rejects.toThrow(ERROR_RECORD_KEY_UNDEFINED); + }); + }); + + describe("entity.updateRecords()", () => { + it("should delegate to service.updateRecords with entity name and data array", async () => { + const entity = createEntityV3WithMethods(makeEntity(), mockService); + mockService.updateRecords = vi.fn().mockResolvedValue(SENTINEL); + + const data = [{ Id: RECORD_ID, ...RECORD_INPUT }]; + const result = await entity.updateRecords(data); + + expect(mockService.updateRecords).toHaveBeenCalledWith(ENTITY_NAME, data, undefined); + expect(result).toBe(SENTINEL); + }); + }); + + describe("entity.updateWhere()", () => { + it("should delegate to service.updateWhere with entity name and request", async () => { + const entity = createEntityV3WithMethods(makeEntity(), mockService); + mockService.updateWhere = vi.fn().mockResolvedValue(SENTINEL); + + const result = await entity.updateWhere(CONDITIONAL_UPDATE_REQUEST); + + expect(mockService.updateWhere).toHaveBeenCalledWith(ENTITY_NAME, CONDITIONAL_UPDATE_REQUEST, undefined); + expect(result).toBe(SENTINEL); + }); + }); + + describe("entity.deleteRecord()", () => { + it("should delegate to service.deleteRecord with entity name and recordId", async () => { + const entity = createEntityV3WithMethods(makeEntity(), mockService); + mockService.deleteRecord = vi.fn().mockResolvedValue(SENTINEL); + + const result = await entity.deleteRecord(RECORD_ID); + + expect(mockService.deleteRecord).toHaveBeenCalledWith(ENTITY_NAME, RECORD_ID, undefined); + expect(result).toBe(SENTINEL); + }); + + it("should throw if recordId is missing", async () => { + const entity = createEntityV3WithMethods(makeEntity(), mockService); + await expect(entity.deleteRecord("")).rejects.toThrow(ERROR_RECORD_ID_UNDEFINED); + }); + }); + + describe("entity.deleteRecords()", () => { + it("should delegate to service.deleteRecords with entity name and record ids", async () => { + const entity = createEntityV3WithMethods(makeEntity(), mockService); + mockService.deleteRecords = vi.fn().mockResolvedValue(SENTINEL); + + const ids = [RECORD_ID, RECORD_ID_2]; + const result = await entity.deleteRecords(ids); + + expect(mockService.deleteRecords).toHaveBeenCalledWith(ENTITY_NAME, ids, undefined); + expect(result).toBe(SENTINEL); + }); + }); + + describe("entity.deleteRecordsBatch()", () => { + it("should delegate to service.deleteRecordsBatch with entity name and record ids", async () => { + const entity = createEntityV3WithMethods(makeEntity(), mockService); + mockService.deleteRecordsBatch = vi.fn().mockResolvedValue(SENTINEL); + + const ids = [RECORD_ID, RECORD_ID_2]; + const result = await entity.deleteRecordsBatch(ids); + + expect(mockService.deleteRecordsBatch).toHaveBeenCalledWith(ENTITY_NAME, ids, undefined); + expect(result).toBe(SENTINEL); + }); + }); + + describe("entity.downloadAttachment()", () => { + it("should delegate to service.downloadAttachment with entity name, recordId, and fieldName", async () => { + const entity = createEntityV3WithMethods(makeEntity(), mockService); + const blob = new Blob(["data"]); + mockService.downloadAttachment = vi.fn().mockResolvedValue(blob); + + const result = await entity.downloadAttachment(RECORD_ID, FIELD_NAME); + + expect(mockService.downloadAttachment).toHaveBeenCalledWith(ENTITY_NAME, RECORD_ID, FIELD_NAME, undefined); + expect(result).toBe(blob); + }); + + it("should throw if recordId is missing", async () => { + const entity = createEntityV3WithMethods(makeEntity(), mockService); + await expect(entity.downloadAttachment("", FIELD_NAME)).rejects.toThrow(ERROR_RECORD_ID_UNDEFINED); + }); + }); + + describe("entity.uploadAttachment()", () => { + it("should delegate to service.uploadAttachment with entity name, recordId, fieldName, and file", async () => { + const entity = createEntityV3WithMethods(makeEntity(), mockService); + const file = new Blob(["data"], { type: "application/pdf" }); + mockService.uploadAttachment = vi.fn().mockResolvedValue(SENTINEL); + + const result = await entity.uploadAttachment(RECORD_ID, FIELD_NAME, file); + + expect(mockService.uploadAttachment).toHaveBeenCalledWith(ENTITY_NAME, RECORD_ID, FIELD_NAME, file, undefined); + expect(result).toBe(SENTINEL); + }); + + it("should throw if recordId is missing", async () => { + const entity = createEntityV3WithMethods(makeEntity(), mockService); + await expect(entity.uploadAttachment("", FIELD_NAME, new Blob())).rejects.toThrow(ERROR_RECORD_ID_UNDEFINED); + }); + }); + + describe("entity.deleteAttachment()", () => { + it("should delegate to service.deleteAttachment with entity name, recordId, and fieldName", async () => { + const entity = createEntityV3WithMethods(makeEntity(), mockService); + mockService.deleteAttachment = vi.fn().mockResolvedValue(SENTINEL); + + const result = await entity.deleteAttachment(RECORD_ID, FIELD_NAME); + + expect(mockService.deleteAttachment).toHaveBeenCalledWith(ENTITY_NAME, RECORD_ID, FIELD_NAME, undefined); + expect(result).toBe(SENTINEL); + }); + + it("should throw if recordId is missing", async () => { + const entity = createEntityV3WithMethods(makeEntity(), mockService); + await expect(entity.deleteAttachment("", FIELD_NAME)).rejects.toThrow(ERROR_RECORD_ID_UNDEFINED); + }); + }); + }); + + // ------------------------------------------------------------------------- + // Composite member bound methods — addressed by entity name + member name + // ------------------------------------------------------------------------- + describe("composite member bound methods (addressed by entity name + member name)", () => { + describe("entity.queryMember()", () => { + it("should delegate to service.queryMember with entity name, member name, and options", async () => { + const entity = createEntityV3WithMethods(makeEntity(), mockService); + mockService.queryMember = vi.fn().mockResolvedValue(SENTINEL); + + const result = await entity.queryMember(MEMBER_NAME, QUERY_OPTIONS); + + expect(mockService.queryMember).toHaveBeenCalledWith(ENTITY_NAME, MEMBER_NAME, QUERY_OPTIONS); + expect(result).toBe(SENTINEL); + }); + + it("should throw if member name is missing", async () => { + const entity = createEntityV3WithMethods(makeEntity(), mockService); + await expect(entity.queryMember("")).rejects.toThrow(ERROR_MEMBER_UNDEFINED); + }); + }); + + describe("entity.getMemberRecords()", () => { + it("should delegate to service.getMemberRecords with entity name and member name", async () => { + const entity = createEntityV3WithMethods(makeEntity(), mockService); + mockService.getMemberRecords = vi.fn().mockResolvedValue(SENTINEL); + + const result = await entity.getMemberRecords(MEMBER_NAME); + + expect(mockService.getMemberRecords).toHaveBeenCalledWith(ENTITY_NAME, MEMBER_NAME, undefined); + expect(result).toBe(SENTINEL); + }); + + it("should throw if member name is missing", async () => { + const entity = createEntityV3WithMethods(makeEntity(), mockService); + await expect(entity.getMemberRecords("")).rejects.toThrow(ERROR_MEMBER_UNDEFINED); + }); + }); + + describe("entity.getMemberRecord()", () => { + it("should delegate to service.getMemberRecord with entity name, member name, and recordId", async () => { + const entity = createEntityV3WithMethods(makeEntity(), mockService); + mockService.getMemberRecord = vi.fn().mockResolvedValue(SENTINEL); + + const result = await entity.getMemberRecord(MEMBER_NAME, RECORD_ID); + + expect(mockService.getMemberRecord).toHaveBeenCalledWith(ENTITY_NAME, MEMBER_NAME, RECORD_ID, undefined); + expect(result).toBe(SENTINEL); + }); + + it("should throw if member name is missing", async () => { + const entity = createEntityV3WithMethods(makeEntity(), mockService); + await expect(entity.getMemberRecord("", RECORD_ID)).rejects.toThrow(ERROR_MEMBER_UNDEFINED); + }); + + it("should throw if recordId is missing", async () => { + const entity = createEntityV3WithMethods(makeEntity(), mockService); + await expect(entity.getMemberRecord(MEMBER_NAME, "")).rejects.toThrow(ERROR_RECORD_ID_UNDEFINED); + }); + }); + + describe("entity.getMemberRecordByKey()", () => { + it("should delegate to service.getMemberRecordByKey with entity name, member name, and key", async () => { + const entity = createEntityV3WithMethods(makeEntity(), mockService); + mockService.getMemberRecordByKey = vi.fn().mockResolvedValue(SENTINEL); + + const result = await entity.getMemberRecordByKey(MEMBER_NAME, RECORD_KEY); + + expect(mockService.getMemberRecordByKey).toHaveBeenCalledWith(ENTITY_NAME, MEMBER_NAME, RECORD_KEY, undefined); + expect(result).toBe(SENTINEL); + }); + + it("should throw if member name is missing", async () => { + const entity = createEntityV3WithMethods(makeEntity(), mockService); + await expect(entity.getMemberRecordByKey("", RECORD_KEY)).rejects.toThrow(ERROR_MEMBER_UNDEFINED); + }); + + it("should throw if key is missing", async () => { + const entity = createEntityV3WithMethods(makeEntity(), mockService); + await expect(entity.getMemberRecordByKey(MEMBER_NAME, "")).rejects.toThrow(ERROR_RECORD_KEY_UNDEFINED); + }); + }); + + describe("entity.deleteMemberRecord()", () => { + it("should delegate to service.deleteMemberRecord with entity name, member name, and recordId", async () => { + const entity = createEntityV3WithMethods(makeEntity(), mockService); + mockService.deleteMemberRecord = vi.fn().mockResolvedValue(SENTINEL); + + const result = await entity.deleteMemberRecord(MEMBER_NAME, RECORD_ID); + + expect(mockService.deleteMemberRecord).toHaveBeenCalledWith(ENTITY_NAME, MEMBER_NAME, RECORD_ID, undefined); + expect(result).toBe(SENTINEL); + }); + + it("should throw if member name is missing", async () => { + const entity = createEntityV3WithMethods(makeEntity(), mockService); + await expect(entity.deleteMemberRecord("", RECORD_ID)).rejects.toThrow(ERROR_MEMBER_UNDEFINED); + }); + + it("should throw if recordId is missing", async () => { + const entity = createEntityV3WithMethods(makeEntity(), mockService); + await expect(entity.deleteMemberRecord(MEMBER_NAME, "")).rejects.toThrow(ERROR_RECORD_ID_UNDEFINED); + }); + }); + + describe("entity.deleteMemberRecords()", () => { + it("should delegate to service.deleteMemberRecords with entity name, member name, and record ids", async () => { + const entity = createEntityV3WithMethods(makeEntity(), mockService); + mockService.deleteMemberRecords = vi.fn().mockResolvedValue(SENTINEL); + + const ids = [RECORD_ID, RECORD_ID_2]; + const result = await entity.deleteMemberRecords(MEMBER_NAME, ids); + + expect(mockService.deleteMemberRecords).toHaveBeenCalledWith(ENTITY_NAME, MEMBER_NAME, ids, undefined); + expect(result).toBe(SENTINEL); + }); + + it("should throw if member name is missing", async () => { + const entity = createEntityV3WithMethods(makeEntity(), mockService); + await expect(entity.deleteMemberRecords("", [RECORD_ID])).rejects.toThrow(ERROR_MEMBER_UNDEFINED); + }); + }); + + describe("entity.createMemberField()", () => { + it("should delegate to service.createMemberField with entity name, member name, and request", async () => { + const entity = createEntityV3WithMethods(makeEntity(), mockService); + mockService.createMemberField = vi.fn().mockResolvedValue(FIELD_ID); + + const result = await entity.createMemberField(MEMBER_NAME, FIELD_CREATE_REQUEST); + + expect(mockService.createMemberField).toHaveBeenCalledWith(ENTITY_NAME, MEMBER_NAME, FIELD_CREATE_REQUEST, undefined); + expect(result).toBe(FIELD_ID); + }); + + it("should throw if member name is missing", async () => { + const entity = createEntityV3WithMethods(makeEntity(), mockService); + await expect(entity.createMemberField("", FIELD_CREATE_REQUEST)).rejects.toThrow(ERROR_MEMBER_UNDEFINED); + }); + }); + + describe("entity.updateMemberField()", () => { + it("should delegate to service.updateMemberField with entity name, member name, fieldId, and request", async () => { + const entity = createEntityV3WithMethods(makeEntity(), mockService); + mockService.updateMemberField = vi.fn().mockResolvedValue(true); + + const result = await entity.updateMemberField(MEMBER_NAME, FIELD_ID, FIELD_UPDATE_REQUEST); + + expect(mockService.updateMemberField).toHaveBeenCalledWith(ENTITY_NAME, MEMBER_NAME, FIELD_ID, FIELD_UPDATE_REQUEST, undefined); + expect(result).toBe(true); + }); + + it("should throw if member name is missing", async () => { + const entity = createEntityV3WithMethods(makeEntity(), mockService); + await expect(entity.updateMemberField("", FIELD_ID, FIELD_UPDATE_REQUEST)).rejects.toThrow(ERROR_MEMBER_UNDEFINED); + }); + + it("should throw if field id is missing", async () => { + const entity = createEntityV3WithMethods(makeEntity(), mockService); + await expect(entity.updateMemberField(MEMBER_NAME, "", FIELD_UPDATE_REQUEST)).rejects.toThrow(ERROR_FIELD_ID_UNDEFINED); + }); + }); + + describe("entity.deleteMemberField()", () => { + it("should delegate to service.deleteMemberField with entity name, member name, and fieldId", async () => { + const entity = createEntityV3WithMethods(makeEntity(), mockService); + mockService.deleteMemberField = vi.fn().mockResolvedValue(true); + + const result = await entity.deleteMemberField(MEMBER_NAME, FIELD_ID); + + expect(mockService.deleteMemberField).toHaveBeenCalledWith(ENTITY_NAME, MEMBER_NAME, FIELD_ID, undefined); + expect(result).toBe(true); + }); + + it("should throw if member name is missing", async () => { + const entity = createEntityV3WithMethods(makeEntity(), mockService); + await expect(entity.deleteMemberField("", FIELD_ID)).rejects.toThrow(ERROR_MEMBER_UNDEFINED); + }); + + it("should throw if field id is missing", async () => { + const entity = createEntityV3WithMethods(makeEntity(), mockService); + await expect(entity.deleteMemberField(MEMBER_NAME, "")).rejects.toThrow(ERROR_FIELD_ID_UNDEFINED); + }); + }); + + describe("entity.deleteMemberFieldHard()", () => { + it("should delegate to service.deleteMemberFieldHard with entity name, member name, and fieldId", async () => { + const entity = createEntityV3WithMethods(makeEntity(), mockService); + mockService.deleteMemberFieldHard = vi.fn().mockResolvedValue(true); + + const result = await entity.deleteMemberFieldHard(MEMBER_NAME, FIELD_ID); + + expect(mockService.deleteMemberFieldHard).toHaveBeenCalledWith(ENTITY_NAME, MEMBER_NAME, FIELD_ID, undefined); + expect(result).toBe(true); + }); + + it("should throw if member name is missing", async () => { + const entity = createEntityV3WithMethods(makeEntity(), mockService); + await expect(entity.deleteMemberFieldHard("", FIELD_ID)).rejects.toThrow(ERROR_MEMBER_UNDEFINED); + }); + + it("should throw if field id is missing", async () => { + const entity = createEntityV3WithMethods(makeEntity(), mockService); + await expect(entity.deleteMemberFieldHard(MEMBER_NAME, "")).rejects.toThrow(ERROR_FIELD_ID_UNDEFINED); + }); + }); + }); + + // ------------------------------------------------------------------------- + // Schema-operation bound methods — addressed by the entity's ID + // ------------------------------------------------------------------------- + describe("schema-operation bound methods (addressed by entity id)", () => { + describe("entity.createField()", () => { + it("should delegate to service.createField with entity id and request", async () => { + const entity = createEntityV3WithMethods(makeEntity(), mockService); + mockService.createField = vi.fn().mockResolvedValue(FIELD_ID); + + const result = await entity.createField(FIELD_CREATE_REQUEST, SCHEMA_OPTIONS); + + expect(mockService.createField).toHaveBeenCalledWith(ENTITY_ID, FIELD_CREATE_REQUEST, SCHEMA_OPTIONS); + expect(result).toBe(FIELD_ID); + }); + }); + + describe("entity.updateField()", () => { + it("should delegate to service.updateField with entity id, fieldId, and request", async () => { + const entity = createEntityV3WithMethods(makeEntity(), mockService); + mockService.updateField = vi.fn().mockResolvedValue(true); + + const result = await entity.updateField(FIELD_ID, FIELD_UPDATE_REQUEST); + + expect(mockService.updateField).toHaveBeenCalledWith(ENTITY_ID, FIELD_ID, FIELD_UPDATE_REQUEST, undefined); + expect(result).toBe(true); + }); + + it("should throw if field id is missing", async () => { + const entity = createEntityV3WithMethods(makeEntity(), mockService); + await expect(entity.updateField("", FIELD_UPDATE_REQUEST)).rejects.toThrow(ERROR_FIELD_ID_UNDEFINED); + }); + }); + + describe("entity.deleteField()", () => { + it("should delegate to service.deleteField with entity id and fieldId", async () => { + const entity = createEntityV3WithMethods(makeEntity(), mockService); + mockService.deleteField = vi.fn().mockResolvedValue(true); + + const result = await entity.deleteField(FIELD_ID); + + expect(mockService.deleteField).toHaveBeenCalledWith(ENTITY_ID, FIELD_ID, undefined); + expect(result).toBe(true); + }); + + it("should throw if field id is missing", async () => { + const entity = createEntityV3WithMethods(makeEntity(), mockService); + await expect(entity.deleteField("")).rejects.toThrow(ERROR_FIELD_ID_UNDEFINED); + }); + }); + + describe("entity.updateMetadata()", () => { + it("should delegate to service.updateMetadata with entity id and request", async () => { + const entity = createEntityV3WithMethods(makeEntity(), mockService); + mockService.updateMetadata = vi.fn().mockResolvedValue(true); + + const result = await entity.updateMetadata(UPDATE_METADATA_REQUEST, SCHEMA_OPTIONS); + + expect(mockService.updateMetadata).toHaveBeenCalledWith(ENTITY_ID, UPDATE_METADATA_REQUEST, SCHEMA_OPTIONS); + expect(result).toBe(true); + }); + }); + + describe("entity.delete()", () => { + it("should delegate to service.deleteById using the entity's own id", async () => { + const entity = createEntityV3WithMethods(makeEntity(), mockService); + mockService.deleteById = vi.fn().mockResolvedValue(undefined); + + await entity.delete(); + + expect(mockService.deleteById).toHaveBeenCalledWith(ENTITY_ID, undefined); + }); + + it("should forward folderKey options to service.deleteById", async () => { + const entity = createEntityV3WithMethods(makeEntity(), mockService); + mockService.deleteById = vi.fn().mockResolvedValue(undefined); + + await entity.delete(SCHEMA_OPTIONS); + + expect(mockService.deleteById).toHaveBeenCalledWith(ENTITY_ID, SCHEMA_OPTIONS); + }); + }); + }); + + // ------------------------------------------------------------------------- + // Entity-level guard errors (name for data ops, id for schema ops) + // ------------------------------------------------------------------------- + describe("entity-level guard errors", () => { + it("data-op methods throw when entity name is undefined", async () => { + const entity = createEntityV3WithMethods( + makeEntity({ name: undefined as unknown as string }), + mockService, + ); + + await expect(entity.getRecords()).rejects.toThrow(ERROR_NAME_UNDEFINED); + await expect(entity.query()).rejects.toThrow(ERROR_NAME_UNDEFINED); + await expect(entity.insert(RECORD_INPUT)).rejects.toThrow(ERROR_NAME_UNDEFINED); + await expect(entity.updateWhere(CONDITIONAL_UPDATE_REQUEST)).rejects.toThrow(ERROR_NAME_UNDEFINED); + }); + + it("schema-op methods throw when entity id is undefined", async () => { + const entity = createEntityV3WithMethods( + makeEntity({ id: undefined as unknown as string }), + mockService, + ); + + await expect(entity.createField(FIELD_CREATE_REQUEST)).rejects.toThrow(ERROR_ID_UNDEFINED); + await expect(entity.updateField(FIELD_ID, FIELD_UPDATE_REQUEST)).rejects.toThrow(ERROR_ID_UNDEFINED); + await expect(entity.deleteField(FIELD_ID)).rejects.toThrow(ERROR_ID_UNDEFINED); + await expect(entity.updateMetadata(UPDATE_METADATA_REQUEST)).rejects.toThrow(ERROR_ID_UNDEFINED); + await expect(entity.delete()).rejects.toThrow(ERROR_ID_UNDEFINED); + }); + }); + + // ------------------------------------------------------------------------- + // Metadata + methods combined correctly + // ------------------------------------------------------------------------- + describe("createEntityV3WithMethods combines metadata and methods", () => { + it("should preserve entity metadata fields", () => { + const entity = createEntityV3WithMethods( + makeEntity({ isComposite: true, entityClass: "CaseComposite" }), + mockService, + ); + + expect(entity.id).toBe(ENTITY_ID); + expect(entity.name).toBe(ENTITY_NAME); + expect(entity.displayName).toBe(ENTITY_DISPLAY_NAME); + expect(entity.isComposite).toBe(true); + expect(entity.entityClass).toBe("CaseComposite"); + }); + + it("should attach all bound operation methods", () => { + const entity = createEntityV3WithMethods(makeEntity(), mockService); + + // Data ops + expect(typeof entity.getRecords).toBe("function"); + expect(typeof entity.getRecord).toBe("function"); + expect(typeof entity.insert).toBe("function"); + expect(typeof entity.upsert).toBe("function"); + expect(typeof entity.deleteRecord).toBe("function"); + // Member ops + expect(typeof entity.queryMember).toBe("function"); + expect(typeof entity.createMemberField).toBe("function"); + // Schema ops + expect(typeof entity.createField).toBe("function"); + expect(typeof entity.updateMetadata).toBe("function"); + expect(typeof entity.delete).toBe("function"); + }); + }); +}); diff --git a/tests/unit/services/data-fabric/entities-v3.test.ts b/tests/unit/services/data-fabric/entities-v3.test.ts new file mode 100644 index 000000000..0482bd846 --- /dev/null +++ b/tests/unit/services/data-fabric/entities-v3.test.ts @@ -0,0 +1,1534 @@ +// ===== IMPORTS ===== +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { EntityV3Service } from "../../../../src/services/data-fabric/entities-v3"; +import { ApiClient } from "../../../../src/core/http/api-client"; +import { PaginationHelpers } from "../../../../src/utils/pagination/helpers"; +import { + createServiceTestDependencies, + createMockApiClient, +} from "../../../utils/setup"; +import { createMockError } from "../../../utils/mocks/core"; +import { ValidationError } from "../../../../src/core/errors"; +import { DATA_FABRIC_V3_ENDPOINTS } from "../../../../src/utils/constants/endpoints/data-fabric"; +import { HTTP_METHODS } from "../../../../src/utils/constants/common"; +import { RESPONSE_TYPES } from "../../../../src/utils/constants/headers"; +import { ENTITY_TEST_CONSTANTS } from "../../../utils/constants/entities"; +import { TEST_CONSTANTS } from "../../../utils/constants/common"; +import { EntityJoin } from "../../../../src/models/data-fabric/entities.types"; +import type { + EntityV3CreateRequest, + EntityV3UpdateMetadataRequest, + EntityV3FieldCreateRequest, + EntityV3FieldUpdateRequest, + EntityV3ConditionalUpdateRequest, + EntityV3AutopilotRequest, + EntityV3RecordInput, + EntityV3WriteResponse, + EntityV3Metadata, +} from "../../../../src/models/data-fabric/entities-v3.types"; + +// ===== MOCKING ===== +vi.mock("../../../../src/core/http/api-client"); + +const mocks = vi.hoisted(() => { + return import("../../../utils/mocks/core"); +}); + +vi.mock( + "../../../../src/utils/pagination/helpers", + async () => (await mocks).mockPaginationHelpers, +); + +// ===== LOCAL TEST FIXTURES ===== +// No shared v3 constant set exists, so these local fixtures cover the v3 surface. +// Entity/record/field ids reuse the shared entity constants where they fit. +const V3 = { + ENTITY_ID: ENTITY_TEST_CONSTANTS.ENTITY_ID, + ENTITY_NAME: "LoanCaseForBank", + MEMBER_NAME: "Comments", + RECORD_ID: ENTITY_TEST_CONSTANTS.RECORD_ID, + RECORD_ID_2: ENTITY_TEST_CONSTANTS.RECORD_ID_2, + FIELD_ID: ENTITY_TEST_CONSTANTS.FIELD_ID, + BUSINESS_KEY: "CASE-001", + ATTACHMENT_FIELD: "Avatar", + FOLDER_KEY: "a9f0c1d2-e3b4-45a6-8789-0abcdef12345", + EXPANSION_LEVEL: 2, + ENTITY_CLASS: "CaseComposite", +} as const; + +const FOLDER_HEADER = { "X-UIPATH-FolderKey": V3.FOLDER_KEY }; + +/** A minimal v3 write/record envelope. */ +const createWriteResponse = (): EntityV3WriteResponse => ({ + Id: V3.RECORD_ID, + children: {}, +}); + +/** A minimal v3 entity metadata record (raw — no bound methods). */ +const createMetadata = (): EntityV3Metadata => ({ + id: V3.ENTITY_ID, + name: V3.ENTITY_NAME, + displayName: "Loan Case", + isComposite: true, +}); + +// ===== TEST SUITE ===== +describe("EntityV3Service Unit Tests", () => { + let entityV3Service: EntityV3Service; + let mockApiClient: any; + + beforeEach(() => { + const { instance } = createServiceTestDependencies(); + mockApiClient = createMockApiClient(); + + vi.mocked(ApiClient).mockImplementation(function () { + return mockApiClient; + }); + + vi.mocked(PaginationHelpers.getAll).mockReset(); + + entityV3Service = new EntityV3Service(instance); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + // ----- Listing / metadata ------------------------------------------------- + + describe("getAll", () => { + it("should list entities and pass entityClass param and folderKey header", async () => { + const mockEntities = [createMetadata()]; + mockApiClient.get.mockResolvedValue(mockEntities); + + const result = await entityV3Service.getAll({ + entityClass: V3.ENTITY_CLASS, + folderKey: V3.FOLDER_KEY, + }); + + expect(result).toEqual(mockEntities); + expect(mockApiClient.get).toHaveBeenCalledWith( + DATA_FABRIC_V3_ENDPOINTS.ENTITY.LIST, + { params: { entityClass: V3.ENTITY_CLASS }, headers: FOLDER_HEADER }, + ); + }); + + it("should list entities with empty params/headers when no options provided", async () => { + mockApiClient.get.mockResolvedValue([]); + + await entityV3Service.getAll(); + + expect(mockApiClient.get).toHaveBeenCalledWith( + DATA_FABRIC_V3_ENDPOINTS.ENTITY.LIST, + { params: {}, headers: {} }, + ); + }); + + it("should handle API errors", async () => { + mockApiClient.get.mockRejectedValue( + createMockError(TEST_CONSTANTS.ERROR_MESSAGE), + ); + + await expect(entityV3Service.getAll()).rejects.toThrow( + TEST_CONSTANTS.ERROR_MESSAGE, + ); + }); + }); + + describe("getAllWithChoiceSets", () => { + it("should call the LIST_ALL endpoint with start/limit params and folderKey header", async () => { + const mockResponse = { entities: [createMetadata()], choicesets: [] }; + mockApiClient.get.mockResolvedValue(mockResponse); + + const result = await entityV3Service.getAllWithChoiceSets({ + start: 0, + limit: 50, + folderKey: V3.FOLDER_KEY, + }); + + expect(result).toEqual(mockResponse); + expect(mockApiClient.get).toHaveBeenCalledWith( + DATA_FABRIC_V3_ENDPOINTS.ENTITY.LIST_ALL, + { params: { start: 0, limit: 50 }, headers: FOLDER_HEADER }, + ); + }); + + it("should handle API errors", async () => { + mockApiClient.get.mockRejectedValue( + createMockError(TEST_CONSTANTS.ERROR_MESSAGE), + ); + + await expect( + entityV3Service.getAllWithChoiceSets(), + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + }); + + describe("getFolderEntities", () => { + it("should call the FOLDER_ENTITIES endpoint with folderKey header", async () => { + const mockEntities = [createMetadata()]; + mockApiClient.get.mockResolvedValue(mockEntities); + + const result = await entityV3Service.getFolderEntities({ + folderKey: V3.FOLDER_KEY, + }); + + expect(result).toEqual(mockEntities); + expect(mockApiClient.get).toHaveBeenCalledWith( + DATA_FABRIC_V3_ENDPOINTS.ENTITY.FOLDER_ENTITIES, + { headers: FOLDER_HEADER }, + ); + }); + + it("should handle API errors", async () => { + mockApiClient.get.mockRejectedValue( + createMockError(TEST_CONSTANTS.ERROR_MESSAGE), + ); + + await expect(entityV3Service.getFolderEntities()).rejects.toThrow( + TEST_CONSTANTS.ERROR_MESSAGE, + ); + }); + }); + + describe("getById", () => { + it("should get entity metadata by id with bound methods attached", async () => { + const mockMetadata = createMetadata(); + mockApiClient.get.mockResolvedValue(mockMetadata); + + const result = await entityV3Service.getById(V3.ENTITY_ID, { + folderKey: V3.FOLDER_KEY, + }); + + expect(result.id).toBe(V3.ENTITY_ID); + expect(result.name).toBe(V3.ENTITY_NAME); + + expect(mockApiClient.get).toHaveBeenCalledWith( + DATA_FABRIC_V3_ENDPOINTS.ENTITY.GET_BY_ID(V3.ENTITY_ID), + { headers: FOLDER_HEADER }, + ); + + // Verify bound methods are attached + expect(typeof result.getRecords).toBe("function"); + expect(typeof result.query).toBe("function"); + expect(typeof result.insert).toBe("function"); + expect(typeof result.update).toBe("function"); + expect(typeof result.delete).toBe("function"); + expect(typeof result.createField).toBe("function"); + expect(typeof result.getMemberRecords).toBe("function"); + }); + + it("should handle API errors", async () => { + mockApiClient.get.mockRejectedValue( + createMockError(TEST_CONSTANTS.ERROR_MESSAGE), + ); + + await expect( + entityV3Service.getById(V3.ENTITY_ID), + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + }); + + describe("getMetadata", () => { + it("should get entity metadata by name with bound methods attached", async () => { + const mockMetadata = createMetadata(); + mockApiClient.get.mockResolvedValue(mockMetadata); + + const result = await entityV3Service.getMetadata(V3.ENTITY_NAME); + + expect(result.name).toBe(V3.ENTITY_NAME); + + expect(mockApiClient.get).toHaveBeenCalledWith( + DATA_FABRIC_V3_ENDPOINTS.ENTITY.GET_METADATA(V3.ENTITY_NAME), + { headers: {} }, + ); + + expect(typeof result.getRecords).toBe("function"); + expect(typeof result.query).toBe("function"); + expect(typeof result.delete).toBe("function"); + }); + + it("should handle API errors", async () => { + mockApiClient.get.mockRejectedValue( + createMockError(TEST_CONSTANTS.ERROR_MESSAGE), + ); + + await expect( + entityV3Service.getMetadata(V3.ENTITY_NAME), + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + }); + + // ----- Schema CUD --------------------------------------------------------- + + describe("create", () => { + it("should create an entity via POST and pass folderKey header", async () => { + const request: EntityV3CreateRequest = { + displayName: "Users", + entityDefinition: { name: "Users", fields: [] }, + }; + mockApiClient.post.mockResolvedValue(V3.ENTITY_ID); + + const result = await entityV3Service.create(request, { + folderKey: V3.FOLDER_KEY, + }); + + expect(result).toBe(V3.ENTITY_ID); + expect(mockApiClient.post).toHaveBeenCalledWith( + DATA_FABRIC_V3_ENDPOINTS.ENTITY.CREATE, + request, + { headers: FOLDER_HEADER }, + ); + }); + + it("should handle API errors", async () => { + const request: EntityV3CreateRequest = { displayName: "Users" }; + mockApiClient.post.mockRejectedValue( + createMockError(TEST_CONSTANTS.ERROR_MESSAGE), + ); + + await expect(entityV3Service.create(request)).rejects.toThrow( + TEST_CONSTANTS.ERROR_MESSAGE, + ); + }); + }); + + describe("deleteById", () => { + it("should delete an entity via DELETE and pass folderKey header", async () => { + mockApiClient.delete.mockResolvedValue(undefined); + + await entityV3Service.deleteById(V3.ENTITY_ID, { + folderKey: V3.FOLDER_KEY, + }); + + expect(mockApiClient.delete).toHaveBeenCalledWith( + DATA_FABRIC_V3_ENDPOINTS.ENTITY.DELETE_BY_ID(V3.ENTITY_ID), + { headers: FOLDER_HEADER }, + ); + }); + + it("should handle API errors", async () => { + mockApiClient.delete.mockRejectedValue( + createMockError(TEST_CONSTANTS.ERROR_MESSAGE), + ); + + await expect( + entityV3Service.deleteById(V3.ENTITY_ID), + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + }); + + describe("updateMetadata", () => { + it("should update metadata via PATCH with entityId merged into the body", async () => { + const request: EntityV3UpdateMetadataRequest = { displayName: "Renamed" }; + mockApiClient.patch.mockResolvedValue(true); + + const result = await entityV3Service.updateMetadata( + V3.ENTITY_ID, + request, + { folderKey: V3.FOLDER_KEY }, + ); + + expect(result).toBe(true); + expect(mockApiClient.patch).toHaveBeenCalledWith( + DATA_FABRIC_V3_ENDPOINTS.ENTITY.UPDATE_METADATA(V3.ENTITY_ID), + { entityId: V3.ENTITY_ID, ...request }, + { headers: FOLDER_HEADER }, + ); + }); + + it("should handle API errors", async () => { + mockApiClient.patch.mockRejectedValue( + createMockError(TEST_CONSTANTS.ERROR_MESSAGE), + ); + + await expect( + entityV3Service.updateMetadata(V3.ENTITY_ID, { displayName: "X" }), + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + }); + + describe("createField", () => { + it("should create a field via POST with entityId merged into the body", async () => { + const request: EntityV3FieldCreateRequest = { + fieldDefinition: { name: "Notes" }, + }; + mockApiClient.post.mockResolvedValue(V3.FIELD_ID); + + const result = await entityV3Service.createField(V3.ENTITY_ID, request); + + expect(result).toBe(V3.FIELD_ID); + expect(mockApiClient.post).toHaveBeenCalledWith( + DATA_FABRIC_V3_ENDPOINTS.ENTITY.FIELD.CREATE(V3.ENTITY_ID), + { entityId: V3.ENTITY_ID, ...request }, + { headers: {} }, + ); + }); + + it("should handle API errors", async () => { + mockApiClient.post.mockRejectedValue( + createMockError(TEST_CONSTANTS.ERROR_MESSAGE), + ); + + await expect( + entityV3Service.createField(V3.ENTITY_ID, { + fieldDefinition: { name: "Notes" }, + }), + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + }); + + describe("updateField", () => { + it("should update a field via PATCH with the field id merged into the body", async () => { + const request: EntityV3FieldUpdateRequest = { displayName: "Internal Notes" }; + mockApiClient.patch.mockResolvedValue(true); + + const result = await entityV3Service.updateField( + V3.ENTITY_ID, + V3.FIELD_ID, + request, + ); + + expect(result).toBe(true); + expect(mockApiClient.patch).toHaveBeenCalledWith( + DATA_FABRIC_V3_ENDPOINTS.ENTITY.FIELD.UPDATE(V3.ENTITY_ID, V3.FIELD_ID), + { id: V3.FIELD_ID, ...request }, + { headers: {} }, + ); + }); + + it("should handle API errors", async () => { + mockApiClient.patch.mockRejectedValue( + createMockError(TEST_CONSTANTS.ERROR_MESSAGE), + ); + + await expect( + entityV3Service.updateField(V3.ENTITY_ID, V3.FIELD_ID, {}), + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + }); + + describe("deleteField", () => { + it("should soft-delete a field via DELETE", async () => { + mockApiClient.delete.mockResolvedValue(true); + + const result = await entityV3Service.deleteField( + V3.ENTITY_ID, + V3.FIELD_ID, + ); + + expect(result).toBe(true); + expect(mockApiClient.delete).toHaveBeenCalledWith( + DATA_FABRIC_V3_ENDPOINTS.ENTITY.FIELD.DELETE(V3.ENTITY_ID, V3.FIELD_ID), + { headers: {} }, + ); + }); + + it("should handle API errors", async () => { + mockApiClient.delete.mockRejectedValue( + createMockError(TEST_CONSTANTS.ERROR_MESSAGE), + ); + + await expect( + entityV3Service.deleteField(V3.ENTITY_ID, V3.FIELD_ID), + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + }); + + // ----- Data reads --------------------------------------------------------- + + describe("getRecords", () => { + it("should return records without pagination and pass the READ endpoint + excludeFromPrefix", async () => { + const mockResponse = { items: [createWriteResponse()], totalCount: 1 }; + vi.mocked(PaginationHelpers.getAll).mockResolvedValue(mockResponse); + + const result = await entityV3Service.getRecords(V3.ENTITY_NAME); + + expect(result).toEqual(mockResponse); + expect(PaginationHelpers.getAll).toHaveBeenCalledWith( + expect.objectContaining({ + serviceAccess: expect.any(Object), + getEndpoint: expect.any(Function), + pagination: expect.any(Object), + excludeFromPrefix: ["expansionLevel"], + }), + undefined, + ); + + const [config] = vi.mocked(PaginationHelpers.getAll).mock.calls[0]; + expect(config.getEndpoint()).toBe( + DATA_FABRIC_V3_ENDPOINTS.ENTITY.READ(V3.ENTITY_NAME), + ); + }); + + it("should forward pagination options and strip folderKey into the header", async () => { + vi.mocked(PaginationHelpers.getAll).mockResolvedValue({ + items: [], + totalCount: 0, + }); + + await entityV3Service.getRecords(V3.ENTITY_NAME, { + folderKey: V3.FOLDER_KEY, + pageSize: TEST_CONSTANTS.PAGE_SIZE, + expansionLevel: V3.EXPANSION_LEVEL, + }); + + expect(PaginationHelpers.getAll).toHaveBeenCalledWith( + expect.objectContaining({ headers: FOLDER_HEADER }), + // folderKey stripped; pageSize + expansionLevel survive + { pageSize: TEST_CONSTANTS.PAGE_SIZE, expansionLevel: V3.EXPANSION_LEVEL }, + ); + }); + + it("should handle API errors", async () => { + vi.mocked(PaginationHelpers.getAll).mockRejectedValue( + createMockError(TEST_CONSTANTS.ERROR_MESSAGE), + ); + + await expect( + entityV3Service.getRecords(V3.ENTITY_NAME), + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + }); + + describe("getRecord", () => { + it("should read a single record by id and return it untransformed", async () => { + const mockRecord = { Id: V3.RECORD_ID, CaseId: "CASE-001", CaseStatus: "Open" }; + mockApiClient.get.mockResolvedValue(mockRecord); + + const result = await entityV3Service.getRecord( + V3.ENTITY_NAME, + V3.RECORD_ID, + { expansionLevel: V3.EXPANSION_LEVEL, folderKey: V3.FOLDER_KEY }, + ); + + expect(result).toEqual(mockRecord); + expect(mockApiClient.get).toHaveBeenCalledWith( + DATA_FABRIC_V3_ENDPOINTS.ENTITY.READ_RECORD(V3.ENTITY_NAME, V3.RECORD_ID), + { params: { expansionLevel: V3.EXPANSION_LEVEL }, headers: FOLDER_HEADER }, + ); + }); + + it("should handle API errors", async () => { + mockApiClient.get.mockRejectedValue( + createMockError(TEST_CONSTANTS.ERROR_MESSAGE), + ); + + await expect( + entityV3Service.getRecord(V3.ENTITY_NAME, V3.RECORD_ID), + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + }); + + describe("getRecordByKey", () => { + it("should read a single record by business key and return it untransformed", async () => { + const mockRecord = { Id: V3.RECORD_ID, CaseId: V3.BUSINESS_KEY }; + mockApiClient.get.mockResolvedValue(mockRecord); + + const result = await entityV3Service.getRecordByKey( + V3.ENTITY_NAME, + V3.BUSINESS_KEY, + ); + + expect(result).toEqual(mockRecord); + expect(mockApiClient.get).toHaveBeenCalledWith( + DATA_FABRIC_V3_ENDPOINTS.ENTITY.READ_BY_KEY(V3.ENTITY_NAME, V3.BUSINESS_KEY), + { params: {}, headers: {} }, + ); + }); + + it("should handle API errors", async () => { + mockApiClient.get.mockRejectedValue( + createMockError(TEST_CONSTANTS.ERROR_MESSAGE), + ); + + await expect( + entityV3Service.getRecordByKey(V3.ENTITY_NAME, V3.BUSINESS_KEY), + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + }); + + describe("query", () => { + it("should query via POST with expansionLevel query param and the query excludeFromPrefix set", async () => { + const mockResponse = { items: [createWriteResponse()], totalCount: 1 }; + vi.mocked(PaginationHelpers.getAll).mockResolvedValue(mockResponse); + + const result = await entityV3Service.query(V3.ENTITY_NAME, { + selectedFields: ["CaseId"], + expansionLevel: V3.EXPANSION_LEVEL, + folderKey: V3.FOLDER_KEY, + }); + + expect(result).toEqual(mockResponse); + expect(PaginationHelpers.getAll).toHaveBeenCalledWith( + expect.objectContaining({ + method: HTTP_METHODS.POST, + headers: FOLDER_HEADER, + queryParams: { expansionLevel: V3.EXPANSION_LEVEL }, + excludeFromPrefix: [ + "filterGroup", + "selectedFields", + "sortOptions", + "aggregates", + "groupBy", + "joins", + "childLimit", + ], + }), + // folderKey and expansionLevel stripped; only body query fields remain + { selectedFields: ["CaseId"] }, + ); + + const [config] = vi.mocked(PaginationHelpers.getAll).mock.calls[0]; + expect(config.getEndpoint()).toBe( + DATA_FABRIC_V3_ENDPOINTS.ENTITY.QUERY(V3.ENTITY_NAME), + ); + }); + + it("should throw a ValidationError without calling the API when more than 3 joins are supplied", async () => { + const joins: EntityJoin[] = Array.from({ length: 4 }, (_, i) => ({ + joinFieldName: `field${i}`, + relatedEntityName: `Related${i}`, + relatedFieldName: `relField${i}`, + })); + + await expect( + entityV3Service.query(V3.ENTITY_NAME, { joins }), + ).rejects.toThrow(ValidationError); + + expect(PaginationHelpers.getAll).not.toHaveBeenCalled(); + }); + + it("should allow exactly 3 joins", async () => { + const joins: EntityJoin[] = Array.from({ length: 3 }, (_, i) => ({ + joinFieldName: `field${i}`, + relatedEntityName: `Related${i}`, + relatedFieldName: `relField${i}`, + })); + vi.mocked(PaginationHelpers.getAll).mockResolvedValue({ + items: [], + totalCount: 0, + }); + + await entityV3Service.query(V3.ENTITY_NAME, { joins }); + + expect(PaginationHelpers.getAll).toHaveBeenCalledTimes(1); + }); + + it("should handle API errors", async () => { + vi.mocked(PaginationHelpers.getAll).mockRejectedValue( + createMockError(TEST_CONSTANTS.ERROR_MESSAGE), + ); + + await expect( + entityV3Service.query(V3.ENTITY_NAME), + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + }); + + describe("queryWithExpansion", () => { + it("should POST only the query body fields and pass expansionLevel param + folderKey header", async () => { + const mockResponse = { value: [{ CaseId: "CASE-001" }], totalRecordCount: 1 }; + mockApiClient.post.mockResolvedValue(mockResponse); + + const result = await entityV3Service.queryWithExpansion(V3.ENTITY_NAME, { + selectedFields: ["CaseId"], + childLimit: 100, + expansionLevel: V3.EXPANSION_LEVEL, + folderKey: V3.FOLDER_KEY, + }); + + expect(result).toEqual(mockResponse); + expect(mockApiClient.post).toHaveBeenCalledWith( + DATA_FABRIC_V3_ENDPOINTS.ENTITY.QUERY_EXPANSION(V3.ENTITY_NAME), + { + filterGroup: undefined, + selectedFields: ["CaseId"], + sortOptions: undefined, + aggregates: undefined, + groupBy: undefined, + joins: undefined, + childLimit: 100, + }, + { params: { expansionLevel: V3.EXPANSION_LEVEL }, headers: FOLDER_HEADER }, + ); + }); + + it("should throw a ValidationError without calling the API when more than 3 joins are supplied", async () => { + const joins: EntityJoin[] = Array.from({ length: 4 }, (_, i) => ({ + joinFieldName: `f${i}`, + relatedEntityName: `E${i}`, + relatedFieldName: "Id", + })); + + await expect( + entityV3Service.queryWithExpansion(V3.ENTITY_NAME, { joins }), + ).rejects.toThrow(ValidationError); + expect(mockApiClient.post).not.toHaveBeenCalled(); + }); + + it("should handle API errors", async () => { + mockApiClient.post.mockRejectedValue( + createMockError(TEST_CONSTANTS.ERROR_MESSAGE), + ); + + await expect( + entityV3Service.queryWithExpansion(V3.ENTITY_NAME), + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + }); + + // ----- Data writes -------------------------------------------------------- + + describe("insert", () => { + it("should insert a single record via POST with expansionLevel param and folderKey header", async () => { + const data: EntityV3RecordInput = { Name: "Alice" }; + const mockResponse = createWriteResponse(); + mockApiClient.post.mockResolvedValue(mockResponse); + + const result = await entityV3Service.insert(V3.ENTITY_NAME, data, { + expansionLevel: V3.EXPANSION_LEVEL, + folderKey: V3.FOLDER_KEY, + }); + + expect(result).toEqual(mockResponse); + expect(mockApiClient.post).toHaveBeenCalledWith( + DATA_FABRIC_V3_ENDPOINTS.ENTITY.INSERT(V3.ENTITY_NAME), + data, + { params: { expansionLevel: V3.EXPANSION_LEVEL }, headers: FOLDER_HEADER }, + ); + }); + + it("should handle API errors", async () => { + mockApiClient.post.mockRejectedValue( + createMockError(TEST_CONSTANTS.ERROR_MESSAGE), + ); + + await expect( + entityV3Service.insert(V3.ENTITY_NAME, { Name: "Alice" }), + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + }); + + describe("insertRecords", () => { + it("should batch-insert via POST with expansionLevel + failOnFirst params", async () => { + const data: EntityV3RecordInput[] = [{ Name: "Alice" }, { Name: "Bob" }]; + const mockResponse = { successRecords: data, failureRecords: [] }; + mockApiClient.post.mockResolvedValue(mockResponse); + + const result = await entityV3Service.insertRecords(V3.ENTITY_NAME, data, { + expansionLevel: V3.EXPANSION_LEVEL, + failOnFirst: true, + }); + + expect(result).toEqual(mockResponse); + expect(mockApiClient.post).toHaveBeenCalledWith( + DATA_FABRIC_V3_ENDPOINTS.ENTITY.INSERT_BATCH(V3.ENTITY_NAME), + data, + { + params: { expansionLevel: V3.EXPANSION_LEVEL, failOnFirst: true }, + headers: {}, + }, + ); + }); + + it("should handle API errors", async () => { + mockApiClient.post.mockRejectedValue( + createMockError(TEST_CONSTANTS.ERROR_MESSAGE), + ); + + await expect( + entityV3Service.insertRecords(V3.ENTITY_NAME, [{ Name: "Alice" }]), + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + }); + + describe("insertBulk", () => { + it("should bulk-insert via POST and return the boolean result", async () => { + const data: EntityV3RecordInput[] = [{ Name: "Alice" }]; + mockApiClient.post.mockResolvedValue(true); + + const result = await entityV3Service.insertBulk(V3.ENTITY_NAME, data, { + folderKey: V3.FOLDER_KEY, + }); + + expect(result).toBe(true); + expect(mockApiClient.post).toHaveBeenCalledWith( + DATA_FABRIC_V3_ENDPOINTS.ENTITY.INSERT_BULK(V3.ENTITY_NAME), + data, + { headers: FOLDER_HEADER }, + ); + }); + + it("should handle API errors", async () => { + mockApiClient.post.mockRejectedValue( + createMockError(TEST_CONSTANTS.ERROR_MESSAGE), + ); + + await expect( + entityV3Service.insertBulk(V3.ENTITY_NAME, [{ Name: "Alice" }]), + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + }); + + describe("upsert", () => { + it("should upsert a record via POST with expansionLevel param", async () => { + const data: EntityV3RecordInput = { CustomerId: "CUST-001", Name: "Alice" }; + const mockResponse = createWriteResponse(); + mockApiClient.post.mockResolvedValue(mockResponse); + + const result = await entityV3Service.upsert(V3.ENTITY_NAME, data, { + expansionLevel: V3.EXPANSION_LEVEL, + }); + + expect(result).toEqual(mockResponse); + expect(mockApiClient.post).toHaveBeenCalledWith( + DATA_FABRIC_V3_ENDPOINTS.ENTITY.UPSERT(V3.ENTITY_NAME), + data, + { params: { expansionLevel: V3.EXPANSION_LEVEL }, headers: {} }, + ); + }); + + it("should handle API errors", async () => { + mockApiClient.post.mockRejectedValue( + createMockError(TEST_CONSTANTS.ERROR_MESSAGE), + ); + + await expect( + entityV3Service.upsert(V3.ENTITY_NAME, { Name: "Alice" }), + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + }); + + describe("update", () => { + it("should update a record by id via POST with expansionLevel param", async () => { + const data: EntityV3RecordInput = { Name: "Alice B." }; + const mockResponse = createWriteResponse(); + mockApiClient.post.mockResolvedValue(mockResponse); + + const result = await entityV3Service.update( + V3.ENTITY_NAME, + V3.RECORD_ID, + data, + { expansionLevel: V3.EXPANSION_LEVEL }, + ); + + expect(result).toEqual(mockResponse); + expect(mockApiClient.post).toHaveBeenCalledWith( + DATA_FABRIC_V3_ENDPOINTS.ENTITY.UPDATE(V3.ENTITY_NAME, V3.RECORD_ID), + data, + { params: { expansionLevel: V3.EXPANSION_LEVEL }, headers: {} }, + ); + }); + + it("should handle API errors", async () => { + mockApiClient.post.mockRejectedValue( + createMockError(TEST_CONSTANTS.ERROR_MESSAGE), + ); + + await expect( + entityV3Service.update(V3.ENTITY_NAME, V3.RECORD_ID, { Name: "X" }), + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + }); + + describe("updateByKey", () => { + it("should update a record by business key via POST", async () => { + const data: EntityV3RecordInput = { Name: "Alice B." }; + const mockResponse = createWriteResponse(); + mockApiClient.post.mockResolvedValue(mockResponse); + + const result = await entityV3Service.updateByKey( + V3.ENTITY_NAME, + V3.BUSINESS_KEY, + data, + ); + + expect(result).toEqual(mockResponse); + expect(mockApiClient.post).toHaveBeenCalledWith( + DATA_FABRIC_V3_ENDPOINTS.ENTITY.UPDATE_BY_KEY(V3.ENTITY_NAME, V3.BUSINESS_KEY), + data, + { params: {}, headers: {} }, + ); + }); + + it("should handle API errors", async () => { + mockApiClient.post.mockRejectedValue( + createMockError(TEST_CONSTANTS.ERROR_MESSAGE), + ); + + await expect( + entityV3Service.updateByKey(V3.ENTITY_NAME, V3.BUSINESS_KEY, { Name: "X" }), + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + }); + + describe("updateRecords", () => { + it("should batch-update via POST with expansionLevel + failOnFirst params", async () => { + const data: EntityV3RecordInput[] = [{ Id: V3.RECORD_ID, Name: "Alice B." }]; + const mockResponse = { successRecords: data, failureRecords: [] }; + mockApiClient.post.mockResolvedValue(mockResponse); + + const result = await entityV3Service.updateRecords(V3.ENTITY_NAME, data, { + expansionLevel: V3.EXPANSION_LEVEL, + failOnFirst: true, + }); + + expect(result).toEqual(mockResponse); + expect(mockApiClient.post).toHaveBeenCalledWith( + DATA_FABRIC_V3_ENDPOINTS.ENTITY.UPDATE_BATCH(V3.ENTITY_NAME), + data, + { + params: { expansionLevel: V3.EXPANSION_LEVEL, failOnFirst: true }, + headers: {}, + }, + ); + }); + + it("should handle API errors", async () => { + mockApiClient.post.mockRejectedValue( + createMockError(TEST_CONSTANTS.ERROR_MESSAGE), + ); + + await expect( + entityV3Service.updateRecords(V3.ENTITY_NAME, [{ Id: V3.RECORD_ID }]), + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + }); + + describe("updateWhere", () => { + it("should conditionally update via POST and pass folderKey header", async () => { + const request: EntityV3ConditionalUpdateRequest = { + fieldValues: { Status: "Active" }, + }; + const mockResponse = { ...createWriteResponse(), updatedCount: 5 }; + mockApiClient.post.mockResolvedValue(mockResponse); + + const result = await entityV3Service.updateWhere(V3.ENTITY_NAME, request, { + folderKey: V3.FOLDER_KEY, + }); + + expect(result).toEqual(mockResponse); + expect(mockApiClient.post).toHaveBeenCalledWith( + DATA_FABRIC_V3_ENDPOINTS.ENTITY.UPDATE_WHERE(V3.ENTITY_NAME), + request, + { headers: FOLDER_HEADER }, + ); + }); + + it("should handle API errors", async () => { + mockApiClient.post.mockRejectedValue( + createMockError(TEST_CONSTANTS.ERROR_MESSAGE), + ); + + await expect( + entityV3Service.updateWhere(V3.ENTITY_NAME, { fieldValues: {} }), + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + }); + + describe("deleteRecord", () => { + it("should delete a single record via DELETE", async () => { + const mockResponse = { ...createWriteResponse(), deletedCount: 1 }; + mockApiClient.delete.mockResolvedValue(mockResponse); + + const result = await entityV3Service.deleteRecord(V3.ENTITY_NAME, V3.RECORD_ID); + + expect(result).toEqual(mockResponse); + expect(mockApiClient.delete).toHaveBeenCalledWith( + DATA_FABRIC_V3_ENDPOINTS.ENTITY.DELETE_RECORD(V3.ENTITY_NAME, V3.RECORD_ID), + { headers: {} }, + ); + }); + + it("should handle API errors", async () => { + mockApiClient.delete.mockRejectedValue( + createMockError(TEST_CONSTANTS.ERROR_MESSAGE), + ); + + await expect( + entityV3Service.deleteRecord(V3.ENTITY_NAME, V3.RECORD_ID), + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + }); + + describe("deleteRecords", () => { + it("should delete multiple records via POST with the id array as body", async () => { + const recordIds = [V3.RECORD_ID, V3.RECORD_ID_2]; + const mockResponse = { ...createWriteResponse(), deletedCount: 2 }; + mockApiClient.post.mockResolvedValue(mockResponse); + + const result = await entityV3Service.deleteRecords(V3.ENTITY_NAME, recordIds); + + expect(result).toEqual(mockResponse); + expect(mockApiClient.post).toHaveBeenCalledWith( + DATA_FABRIC_V3_ENDPOINTS.ENTITY.DELETE(V3.ENTITY_NAME), + recordIds, + { headers: {} }, + ); + }); + + it("should handle API errors", async () => { + mockApiClient.post.mockRejectedValue( + createMockError(TEST_CONSTANTS.ERROR_MESSAGE), + ); + + await expect( + entityV3Service.deleteRecords(V3.ENTITY_NAME, [V3.RECORD_ID]), + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + }); + + describe("deleteRecordsBatch", () => { + it("should batch-delete via POST with failOnFirst param", async () => { + const recordIds = [V3.RECORD_ID, V3.RECORD_ID_2]; + const mockResponse = { successRecords: [], failureRecords: [] }; + mockApiClient.post.mockResolvedValue(mockResponse); + + const result = await entityV3Service.deleteRecordsBatch( + V3.ENTITY_NAME, + recordIds, + { failOnFirst: true }, + ); + + expect(result).toEqual(mockResponse); + expect(mockApiClient.post).toHaveBeenCalledWith( + DATA_FABRIC_V3_ENDPOINTS.ENTITY.DELETE_BATCH(V3.ENTITY_NAME), + recordIds, + { params: { failOnFirst: true }, headers: {} }, + ); + }); + + it("should handle API errors", async () => { + mockApiClient.post.mockRejectedValue( + createMockError(TEST_CONSTANTS.ERROR_MESSAGE), + ); + + await expect( + entityV3Service.deleteRecordsBatch(V3.ENTITY_NAME, [V3.RECORD_ID]), + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + }); + + // ----- Members ------------------------------------------------------------ + + describe("queryMember", () => { + it("should query a member via POST with the query excludeFromPrefix set", async () => { + const mockResponse = { items: [{ CommentId: "CMT-001" }], totalCount: 1 }; + vi.mocked(PaginationHelpers.getAll).mockResolvedValue(mockResponse); + + const result = await entityV3Service.queryMember( + V3.ENTITY_NAME, + V3.MEMBER_NAME, + { folderKey: V3.FOLDER_KEY }, + ); + + expect(result).toEqual(mockResponse); + expect(PaginationHelpers.getAll).toHaveBeenCalledWith( + expect.objectContaining({ + method: HTTP_METHODS.POST, + headers: FOLDER_HEADER, + excludeFromPrefix: [ + "filterGroup", + "selectedFields", + "sortOptions", + "aggregates", + "groupBy", + "joins", + "childLimit", + ], + }), + // folderKey stripped into the header, leaving an empty downstream options bag + {}, + ); + + const [config] = vi.mocked(PaginationHelpers.getAll).mock.calls[0]; + expect(config.getEndpoint()).toBe( + DATA_FABRIC_V3_ENDPOINTS.MEMBER.QUERY(V3.ENTITY_NAME, V3.MEMBER_NAME), + ); + }); + + it("should handle API errors", async () => { + vi.mocked(PaginationHelpers.getAll).mockRejectedValue( + createMockError(TEST_CONSTANTS.ERROR_MESSAGE), + ); + + await expect( + entityV3Service.queryMember(V3.ENTITY_NAME, V3.MEMBER_NAME), + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + }); + + describe("getMemberRecords", () => { + it("should read member records via the MEMBER.READ endpoint and strip folderKey into the header", async () => { + const mockResponse = { items: [{ CommentId: "CMT-001" }], totalCount: 1 }; + vi.mocked(PaginationHelpers.getAll).mockResolvedValue(mockResponse); + + const result = await entityV3Service.getMemberRecords( + V3.ENTITY_NAME, + V3.MEMBER_NAME, + { folderKey: V3.FOLDER_KEY, pageSize: TEST_CONSTANTS.PAGE_SIZE }, + ); + + expect(result).toEqual(mockResponse); + expect(PaginationHelpers.getAll).toHaveBeenCalledWith( + expect.objectContaining({ headers: FOLDER_HEADER }), + { pageSize: TEST_CONSTANTS.PAGE_SIZE }, + ); + + const [config] = vi.mocked(PaginationHelpers.getAll).mock.calls[0]; + expect(config.getEndpoint()).toBe( + DATA_FABRIC_V3_ENDPOINTS.MEMBER.READ(V3.ENTITY_NAME, V3.MEMBER_NAME), + ); + }); + + it("should handle API errors", async () => { + vi.mocked(PaginationHelpers.getAll).mockRejectedValue( + createMockError(TEST_CONSTANTS.ERROR_MESSAGE), + ); + + await expect( + entityV3Service.getMemberRecords(V3.ENTITY_NAME, V3.MEMBER_NAME), + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + }); + + describe("getMemberRecord", () => { + it("should read a single member record by id via GET", async () => { + const mockRecord = { Id: V3.RECORD_ID, CommentId: "CMT-001" }; + mockApiClient.get.mockResolvedValue(mockRecord); + + const result = await entityV3Service.getMemberRecord( + V3.ENTITY_NAME, + V3.MEMBER_NAME, + V3.RECORD_ID, + ); + + expect(result).toEqual(mockRecord); + expect(mockApiClient.get).toHaveBeenCalledWith( + DATA_FABRIC_V3_ENDPOINTS.MEMBER.READ_RECORD( + V3.ENTITY_NAME, + V3.MEMBER_NAME, + V3.RECORD_ID, + ), + { headers: {} }, + ); + }); + + it("should handle API errors", async () => { + mockApiClient.get.mockRejectedValue( + createMockError(TEST_CONSTANTS.ERROR_MESSAGE), + ); + + await expect( + entityV3Service.getMemberRecord(V3.ENTITY_NAME, V3.MEMBER_NAME, V3.RECORD_ID), + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + }); + + describe("getMemberRecordByKey", () => { + it("should read a single member record by business key via GET", async () => { + const mockRecord = { Id: V3.RECORD_ID, CommentId: "CMT-001" }; + mockApiClient.get.mockResolvedValue(mockRecord); + + const result = await entityV3Service.getMemberRecordByKey( + V3.ENTITY_NAME, + V3.MEMBER_NAME, + V3.BUSINESS_KEY, + ); + + expect(result).toEqual(mockRecord); + expect(mockApiClient.get).toHaveBeenCalledWith( + DATA_FABRIC_V3_ENDPOINTS.MEMBER.READ_BY_KEY( + V3.ENTITY_NAME, + V3.MEMBER_NAME, + V3.BUSINESS_KEY, + ), + { headers: {} }, + ); + }); + + it("should handle API errors", async () => { + mockApiClient.get.mockRejectedValue( + createMockError(TEST_CONSTANTS.ERROR_MESSAGE), + ); + + await expect( + entityV3Service.getMemberRecordByKey( + V3.ENTITY_NAME, + V3.MEMBER_NAME, + V3.BUSINESS_KEY, + ), + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + }); + + describe("deleteMemberRecord", () => { + it("should delete a single member record via DELETE", async () => { + const mockResponse = { recordId: V3.RECORD_ID, deletedCount: 1 }; + mockApiClient.delete.mockResolvedValue(mockResponse); + + const result = await entityV3Service.deleteMemberRecord( + V3.ENTITY_NAME, + V3.MEMBER_NAME, + V3.RECORD_ID, + ); + + expect(result).toEqual(mockResponse); + expect(mockApiClient.delete).toHaveBeenCalledWith( + DATA_FABRIC_V3_ENDPOINTS.MEMBER.DELETE_RECORD( + V3.ENTITY_NAME, + V3.MEMBER_NAME, + V3.RECORD_ID, + ), + { headers: {} }, + ); + }); + + it("should handle API errors", async () => { + mockApiClient.delete.mockRejectedValue( + createMockError(TEST_CONSTANTS.ERROR_MESSAGE), + ); + + await expect( + entityV3Service.deleteMemberRecord(V3.ENTITY_NAME, V3.MEMBER_NAME, V3.RECORD_ID), + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + }); + + describe("deleteMemberRecords", () => { + it("should delete multiple member records via POST with the id array as body", async () => { + const recordIds = [V3.RECORD_ID, V3.RECORD_ID_2]; + const mockResponse = { deletedCount: 2 }; + mockApiClient.post.mockResolvedValue(mockResponse); + + const result = await entityV3Service.deleteMemberRecords( + V3.ENTITY_NAME, + V3.MEMBER_NAME, + recordIds, + ); + + expect(result).toEqual(mockResponse); + expect(mockApiClient.post).toHaveBeenCalledWith( + DATA_FABRIC_V3_ENDPOINTS.MEMBER.DELETE(V3.ENTITY_NAME, V3.MEMBER_NAME), + recordIds, + { headers: {} }, + ); + }); + + it("should handle API errors", async () => { + mockApiClient.post.mockRejectedValue( + createMockError(TEST_CONSTANTS.ERROR_MESSAGE), + ); + + await expect( + entityV3Service.deleteMemberRecords(V3.ENTITY_NAME, V3.MEMBER_NAME, [V3.RECORD_ID]), + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + }); + + describe("createMemberField", () => { + it("should create a member field via POST", async () => { + const request: EntityV3FieldCreateRequest = { + fieldDefinition: { name: "Priority" }, + }; + mockApiClient.post.mockResolvedValue(V3.FIELD_ID); + + const result = await entityV3Service.createMemberField( + V3.ENTITY_NAME, + V3.MEMBER_NAME, + request, + ); + + expect(result).toBe(V3.FIELD_ID); + expect(mockApiClient.post).toHaveBeenCalledWith( + DATA_FABRIC_V3_ENDPOINTS.MEMBER.FIELD.CREATE(V3.ENTITY_NAME, V3.MEMBER_NAME), + request, + { headers: {} }, + ); + }); + + it("should handle API errors", async () => { + mockApiClient.post.mockRejectedValue( + createMockError(TEST_CONSTANTS.ERROR_MESSAGE), + ); + + await expect( + entityV3Service.createMemberField(V3.ENTITY_NAME, V3.MEMBER_NAME, { + fieldDefinition: { name: "Priority" }, + }), + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + }); + + describe("updateMemberField", () => { + it("should update a member field via PATCH with the field id merged into the body", async () => { + const request: EntityV3FieldUpdateRequest = { displayName: "Priority Level" }; + mockApiClient.patch.mockResolvedValue(true); + + const result = await entityV3Service.updateMemberField( + V3.ENTITY_NAME, + V3.MEMBER_NAME, + V3.FIELD_ID, + request, + ); + + expect(result).toBe(true); + expect(mockApiClient.patch).toHaveBeenCalledWith( + DATA_FABRIC_V3_ENDPOINTS.MEMBER.FIELD.UPDATE( + V3.ENTITY_NAME, + V3.MEMBER_NAME, + V3.FIELD_ID, + ), + { id: V3.FIELD_ID, ...request }, + { headers: {} }, + ); + }); + + it("should handle API errors", async () => { + mockApiClient.patch.mockRejectedValue( + createMockError(TEST_CONSTANTS.ERROR_MESSAGE), + ); + + await expect( + entityV3Service.updateMemberField(V3.ENTITY_NAME, V3.MEMBER_NAME, V3.FIELD_ID, {}), + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + }); + + describe("deleteMemberField", () => { + it("should soft-delete a member field via DELETE", async () => { + mockApiClient.delete.mockResolvedValue(true); + + const result = await entityV3Service.deleteMemberField( + V3.ENTITY_NAME, + V3.MEMBER_NAME, + V3.FIELD_ID, + ); + + expect(result).toBe(true); + expect(mockApiClient.delete).toHaveBeenCalledWith( + DATA_FABRIC_V3_ENDPOINTS.MEMBER.FIELD.DELETE( + V3.ENTITY_NAME, + V3.MEMBER_NAME, + V3.FIELD_ID, + ), + { headers: {} }, + ); + }); + + it("should handle API errors", async () => { + mockApiClient.delete.mockRejectedValue( + createMockError(TEST_CONSTANTS.ERROR_MESSAGE), + ); + + await expect( + entityV3Service.deleteMemberField(V3.ENTITY_NAME, V3.MEMBER_NAME, V3.FIELD_ID), + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + }); + + describe("deleteMemberFieldHard", () => { + it("should hard-delete a member field via POST with an undefined body", async () => { + mockApiClient.post.mockResolvedValue(true); + + const result = await entityV3Service.deleteMemberFieldHard( + V3.ENTITY_NAME, + V3.MEMBER_NAME, + V3.FIELD_ID, + ); + + expect(result).toBe(true); + expect(mockApiClient.post).toHaveBeenCalledWith( + DATA_FABRIC_V3_ENDPOINTS.MEMBER.FIELD.DELETE_HARD( + V3.ENTITY_NAME, + V3.MEMBER_NAME, + V3.FIELD_ID, + ), + undefined, + { headers: {} }, + ); + }); + + it("should handle API errors", async () => { + mockApiClient.post.mockRejectedValue( + createMockError(TEST_CONSTANTS.ERROR_MESSAGE), + ); + + await expect( + entityV3Service.deleteMemberFieldHard(V3.ENTITY_NAME, V3.MEMBER_NAME, V3.FIELD_ID), + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + }); + + // ----- Attachments -------------------------------------------------------- + + describe("downloadAttachment", () => { + it("should download an attachment via GET with the blob response type", async () => { + const mockBlob = new Blob(["file-content"]); + mockApiClient.get.mockResolvedValue(mockBlob); + + const result = await entityV3Service.downloadAttachment( + V3.ENTITY_NAME, + V3.RECORD_ID, + V3.ATTACHMENT_FIELD, + { folderKey: V3.FOLDER_KEY }, + ); + + expect(result).toBe(mockBlob); + expect(mockApiClient.get).toHaveBeenCalledWith( + DATA_FABRIC_V3_ENDPOINTS.ENTITY.ATTACHMENT.DOWNLOAD( + V3.ENTITY_NAME, + V3.RECORD_ID, + V3.ATTACHMENT_FIELD, + ), + { responseType: RESPONSE_TYPES.BLOB, headers: FOLDER_HEADER }, + ); + }); + + it("should handle API errors", async () => { + mockApiClient.get.mockRejectedValue( + createMockError(TEST_CONSTANTS.ERROR_MESSAGE), + ); + + await expect( + entityV3Service.downloadAttachment(V3.ENTITY_NAME, V3.RECORD_ID, V3.ATTACHMENT_FIELD), + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + }); + + describe("uploadAttachment", () => { + it("should upload an attachment via POST with a FormData body and expansionLevel param", async () => { + const file = new Blob(["file-content"]); + const mockResponse = { Id: V3.RECORD_ID, [V3.ATTACHMENT_FIELD]: "uploaded" }; + mockApiClient.post.mockResolvedValue(mockResponse); + + const result = await entityV3Service.uploadAttachment( + V3.ENTITY_NAME, + V3.RECORD_ID, + V3.ATTACHMENT_FIELD, + file, + { expansionLevel: V3.EXPANSION_LEVEL, folderKey: V3.FOLDER_KEY }, + ); + + expect(result).toEqual(mockResponse); + expect(mockApiClient.post).toHaveBeenCalledWith( + DATA_FABRIC_V3_ENDPOINTS.ENTITY.ATTACHMENT.UPLOAD( + V3.ENTITY_NAME, + V3.RECORD_ID, + V3.ATTACHMENT_FIELD, + ), + expect.any(FormData), + { params: { expansionLevel: V3.EXPANSION_LEVEL }, headers: FOLDER_HEADER }, + ); + }); + + it("should handle API errors", async () => { + const file = new Blob(["file-content"]); + mockApiClient.post.mockRejectedValue( + createMockError(TEST_CONSTANTS.ERROR_MESSAGE), + ); + + await expect( + entityV3Service.uploadAttachment( + V3.ENTITY_NAME, + V3.RECORD_ID, + V3.ATTACHMENT_FIELD, + file, + ), + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + }); + + describe("deleteAttachment", () => { + it("should delete an attachment via DELETE with expansionLevel param", async () => { + const mockResponse = { Id: V3.RECORD_ID, [V3.ATTACHMENT_FIELD]: null }; + mockApiClient.delete.mockResolvedValue(mockResponse); + + const result = await entityV3Service.deleteAttachment( + V3.ENTITY_NAME, + V3.RECORD_ID, + V3.ATTACHMENT_FIELD, + { expansionLevel: V3.EXPANSION_LEVEL }, + ); + + expect(result).toEqual(mockResponse); + expect(mockApiClient.delete).toHaveBeenCalledWith( + DATA_FABRIC_V3_ENDPOINTS.ENTITY.ATTACHMENT.DELETE( + V3.ENTITY_NAME, + V3.RECORD_ID, + V3.ATTACHMENT_FIELD, + ), + { params: { expansionLevel: V3.EXPANSION_LEVEL }, headers: {} }, + ); + }); + + it("should handle API errors", async () => { + mockApiClient.delete.mockRejectedValue( + createMockError(TEST_CONSTANTS.ERROR_MESSAGE), + ); + + await expect( + entityV3Service.deleteAttachment(V3.ENTITY_NAME, V3.RECORD_ID, V3.ATTACHMENT_FIELD), + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + }); + + // ----- Autopilot ---------------------------------------------------------- + + describe("manageWithAutopilot", () => { + it("should POST the autopilot request and return the response", async () => { + const request: EntityV3AutopilotRequest = { + query: "Create a Customers entity with a name field", + }; + const mockResponse = { isSuccess: true, action: "create_entity" }; + mockApiClient.post.mockResolvedValue(mockResponse); + + const result = await entityV3Service.manageWithAutopilot(request); + + expect(result).toEqual(mockResponse); + expect(mockApiClient.post).toHaveBeenCalledWith( + DATA_FABRIC_V3_ENDPOINTS.ENTITY.AUTOPILOT.MANAGE, + request, + {}, + ); + }); + + it("should handle API errors", async () => { + mockApiClient.post.mockRejectedValue( + createMockError(TEST_CONSTANTS.ERROR_MESSAGE), + ); + + await expect( + entityV3Service.manageWithAutopilot({ query: "hi" }), + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + }); + + describe("manageWithAutopilotStream", () => { + it("should POST with the stream response type and return the ReadableStream", async () => { + const request: EntityV3AutopilotRequest = { query: "Add a status field" }; + const mockStream = new ReadableStream(); + mockApiClient.post.mockResolvedValue(mockStream); + + const result = await entityV3Service.manageWithAutopilotStream(request); + + expect(result).toBe(mockStream); + expect(mockApiClient.post).toHaveBeenCalledWith( + DATA_FABRIC_V3_ENDPOINTS.ENTITY.AUTOPILOT.MANAGE_STREAM, + request, + { responseType: RESPONSE_TYPES.STREAM }, + ); + }); + + it("should handle API errors", async () => { + mockApiClient.post.mockRejectedValue( + createMockError(TEST_CONSTANTS.ERROR_MESSAGE), + ); + + await expect( + entityV3Service.manageWithAutopilotStream({ query: "hi" }), + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + }); +});