From 1753cd049899b9f898a18c083165a4c2bd8c25c9 Mon Sep 17 00:00:00 2001 From: Max Ewing Date: Wed, 13 Jan 2021 00:10:51 +0000 Subject: [PATCH] feat: test data setup as named user (#58) +semver: feature --- README.md | 55 +++-- ...apgemini.PowerApps.SpecFlowBindings.csproj | 1 + .../Configuration/ClientCredentials.cs | 32 +++ .../Configuration/ConfigHelper.cs | 27 +++ .../Configuration/TestConfiguration.cs | 30 +-- .../Configuration/UserConfiguration.cs | 7 +- .../ITestDriver.cs | 13 ++ .../PowerAppsStepDefiner.cs | 55 ++++- .../Steps/DataSteps.cs | 15 ++ .../TestDriver.cs | 46 +++-- .../Data/a different team.json | 5 + .../DataSteps.feature | 5 +- .../DialogSteps.feature | 4 +- .../power-apps-bindings.yml | 8 +- driver/src/data/createOptions.ts | 3 + driver/src/data/dataManager.ts | 61 +++++- driver/src/data/deepInsertService.ts | 24 ++- driver/src/data/fakerPreprocessor.ts | 2 +- driver/src/data/index.ts | 2 +- driver/src/data/preprocessor.ts | 2 +- driver/src/data/record.ts | 2 +- driver/src/data/testRecord.ts | 2 +- driver/src/driver.ts | 21 +- driver/src/index.ts | 2 +- .../authenticatedRecordRepository.ts | 192 ++++++++++++++++++ .../currentUserRecordRepository.ts | 111 ++++++++++ driver/src/repositories/index.ts | 3 +- driver/src/repositories/metadataRepository.ts | 7 +- driver/src/repositories/recordRepository.ts | 85 +------- driver/src/repositories/repository.ts | 7 - driver/test/data/dataManager.spec.ts | 60 +++++- driver/test/data/deepInsertService.spec.ts | 8 + driver/test/driver.spec.ts | 18 ++ .../authenticatedUserRecordRepository.spec.ts | 139 +++++++++++++ .../currentUserRecordRepository.spec.ts | 129 ++++++++++++ .../repositories/metadataRepository.spec.ts | 11 +- .../repositories/recordRepository.spec.ts | 79 ------- driver/tsconfig.json | 6 +- templates/include-build-and-test-steps.yml | 4 +- 39 files changed, 1020 insertions(+), 263 deletions(-) create mode 100644 bindings/src/Capgemini.PowerApps.SpecFlowBindings/Configuration/ClientCredentials.cs create mode 100644 bindings/src/Capgemini.PowerApps.SpecFlowBindings/Configuration/ConfigHelper.cs create mode 100644 bindings/tests/Capgemini.PowerApps.SpecFlowBindings.UiTests/Data/a different team.json create mode 100644 driver/src/data/createOptions.ts create mode 100644 driver/src/repositories/authenticatedRecordRepository.ts create mode 100644 driver/src/repositories/currentUserRecordRepository.ts delete mode 100644 driver/src/repositories/repository.ts create mode 100644 driver/test/repositories/authenticatedUserRecordRepository.spec.ts create mode 100644 driver/test/repositories/currentUserRecordRepository.spec.ts delete mode 100644 driver/test/repositories/recordRepository.spec.ts diff --git a/README.md b/README.md index 0e3cf33..e8517dd 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,9 @@ The aim of this project is to make Power Apps test automation easier, faster and - [Writing feature files](#Writing-feature-files) - [Writing step bindings](#Writing-step-bindings) - [Test setup](#Test-setup) + - [Bindings](#Bindings) + - [Data file syntax](#Data-file-syntax) + - [Dynamic data](#Dynamic-data) - [Contributing](#Contributing) - [Licence](#Licence) @@ -50,20 +53,24 @@ PM> Install-Package Selenium.Chrome.WebDriver Installing the NuGet package creates a _power-apps-bindings.yml_ file in your project's root. This is used to configure the URL, browser, and users that will be used for your tests. ```yaml -url: SPECFLOW_POWERAPPS_URL -browserOptions: +url: SPECFLOW_POWERAPPS_URL # mandatory +browserOptions: # optional - will use default EasyRepro options if not set browserType: Chrome headless: true width: 1920 height: 1080 startMaximized: false -users: - - username: SPECFLOW_POWERAPPS_USERNAME_SALESPERSON - password: SPECFLOW_POWERAPPS_PASSWORD_SALESPERSON - alias: a salesperson +applicationUser: # optional - populate if creating test data for users other than the current user + tenantId: SPECFLOW_POWERAPPS_TENANTID optional # mandatory + clientId: SPECFLOW_POWERAPPS_CLIENTID # mandatory + clientSecret: SPECFLOW_POWERAPPS_CLIENTSECRET # mandatory +users: # mandatory + - username: SPECFLOW_POWERAPPS_USERNAME_SALESPERSON # mandatory + password: SPECFLOW_POWERAPPS_PASSWORD_SALESPERSON # optional - populate if this user will be logging in for tests + alias: a salesperson # mandatory ``` -The URL, usernames, and passwords will be set from environment variable (if found). Otherwise, the value from the config file will be used. The browserOptions node supports anything in the EasyRepro `BrowserOptions` class. +The URL, usernames, passwords, and application user details will be set from environment variable (if found). Otherwise, the value from the config file will be used. The browserOptions node supports anything in the EasyRepro `BrowserOptions` class. ### Writing feature files @@ -97,20 +104,30 @@ public class MyCustomSteps : PowerAppsStepDefiner ### Test setup +#### Bindings + We avoid performing test setup via the UI. This speeds up test execution and makes the tests more robust (as UI automation is more fragile than using supported APIs). _Given_ steps should therefore be carried out using the [Client API](client-api), [WebAPI](web-api) or [Organization Service](org-service). -You can create test data by using the following _Given_ step - +You can create test data by using the following _Given_ steps - ```gherkin -Given I have created "a record" +Given I have created 'a record' +``` +or +```gherkin +Given 'someone' has created 'a record' ``` -This will look for a JSON file named _a record.json_ in the _data_ folder (you must ensure that these files are copying to the build output directory). The JSON is the same as expected by WebAPI when creating records via a [deep insert](https://docs.microsoft.com/en-us/dynamics365/customer-engagement/developer/webapi/create-entity-web-api#create-related-entities-in-one-operation). The example below will create the following - +The examples above will both look for a JSON file named _a record.json_ in the _data_ folder (you must ensure that these files are copying to the build output directory). The difference is that the latter requires the following in the configuration file: -- An account -- An primary contact related to the account -- An opportunity related to the account -- A task related to the opportunity +- a user with an alias of _someone_ in the `users` array +- an application user with sufficient privileges to impersonate the above user configured in the `applicationUser` property. + +Refer to the Microsoft documentation on creating an application user [here](https://docs.microsoft.com/en-us/power-platform/admin/create-users-assign-online-security-roles#create-an-application-user). + +#### Data file syntax + +The JSON is similar to that expected by Web API when creating records via a [deep insert](https://docs.microsoft.com/en-us/dynamics365/customer-engagement/developer/webapi/create-entity-web-api#create-related-entities-in-one-operation). ```json { @@ -129,10 +146,16 @@ This will look for a JSON file named _a record.json_ in the _data_ folder (you m ] } ``` +The example above will create the following: + +- An account +- An primary contact related to the account +- An opportunity related to the account +- A task related to the opportunity The `@logicalName` property is required for the root record. -The `@alias` property can optionally be added to any record and allows the record to be referenced in certain bindings. The _Given I have created_ binding itself supports relating records using `@alias.bind` syntax. +The `@alias` property can optionally be added to any record and allows the record to be referenced in certain bindings. The _Given I have created_ binding itself supports relating records using `@alias.bind` syntax, as shown below: ```json { @@ -143,6 +166,8 @@ The `@alias` property can optionally be added to any record and allows the recor } ``` +#### Dynamic data + We also support the use of [faker.js](https://github.com/marak/Faker.js) moustache template syntax for generating dynamic test data at run-time. Please refer to the faker documentation for all of the functionality that is available. diff --git a/bindings/src/Capgemini.PowerApps.SpecFlowBindings/Capgemini.PowerApps.SpecFlowBindings.csproj b/bindings/src/Capgemini.PowerApps.SpecFlowBindings/Capgemini.PowerApps.SpecFlowBindings.csproj index 4dcffb6..d353c6d 100644 --- a/bindings/src/Capgemini.PowerApps.SpecFlowBindings/Capgemini.PowerApps.SpecFlowBindings.csproj +++ b/bindings/src/Capgemini.PowerApps.SpecFlowBindings/Capgemini.PowerApps.SpecFlowBindings.csproj @@ -37,6 +37,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + all diff --git a/bindings/src/Capgemini.PowerApps.SpecFlowBindings/Configuration/ClientCredentials.cs b/bindings/src/Capgemini.PowerApps.SpecFlowBindings/Configuration/ClientCredentials.cs new file mode 100644 index 0000000..94f32ad --- /dev/null +++ b/bindings/src/Capgemini.PowerApps.SpecFlowBindings/Configuration/ClientCredentials.cs @@ -0,0 +1,32 @@ +namespace Capgemini.PowerApps.SpecFlowBindings.Configuration +{ + using YamlDotNet.Serialization; + + /// + /// Configuration for the test application user. + /// + public class ClientCredentials + { + private string tenantId; + private string clientId; + private string clientSecret; + + /// + /// Gets or sets the tenant ID of the test application user app registration. + /// + [YamlMember(Alias = "tenantId")] + public string TenantId { get => ConfigHelper.GetEnvironmentVariableIfExists(this.tenantId); set => this.tenantId = value; } + + /// + /// Gets or sets the client ID of the test application user app registration. + /// + [YamlMember(Alias = "clientId")] + public string ClientId { get => ConfigHelper.GetEnvironmentVariableIfExists(this.clientId); set => this.clientId = value; } + + /// + /// Gets or sets a client secret or the name of an environment variable containing the client secret of the test application user app registration. + /// + [YamlMember(Alias = "clientSecret")] + public string ClientSecret { get => ConfigHelper.GetEnvironmentVariableIfExists(this.clientSecret); set => this.clientSecret = value; } + } +} diff --git a/bindings/src/Capgemini.PowerApps.SpecFlowBindings/Configuration/ConfigHelper.cs b/bindings/src/Capgemini.PowerApps.SpecFlowBindings/Configuration/ConfigHelper.cs new file mode 100644 index 0000000..9aa5274 --- /dev/null +++ b/bindings/src/Capgemini.PowerApps.SpecFlowBindings/Configuration/ConfigHelper.cs @@ -0,0 +1,27 @@ +namespace Capgemini.PowerApps.SpecFlowBindings.Configuration +{ + using System; + + /// + /// Helper methods for configuration classes. + /// + public static class ConfigHelper + { + /// + /// Returns the value of an environment variable if it exists. Alternatively, returns the passed in value. + /// + /// The value which may be the name of an environment variable. + /// The environment variable value (if found) or the passed in value. + public static string GetEnvironmentVariableIfExists(string value) + { + var environmentVariableValue = Environment.GetEnvironmentVariable(value); + + if (!string.IsNullOrEmpty(environmentVariableValue)) + { + return environmentVariableValue; + } + + return value; + } + } +} diff --git a/bindings/src/Capgemini.PowerApps.SpecFlowBindings/Configuration/TestConfiguration.cs b/bindings/src/Capgemini.PowerApps.SpecFlowBindings/Configuration/TestConfiguration.cs index 93990f2..0c4a1b7 100644 --- a/bindings/src/Capgemini.PowerApps.SpecFlowBindings/Configuration/TestConfiguration.cs +++ b/bindings/src/Capgemini.PowerApps.SpecFlowBindings/Configuration/TestConfiguration.cs @@ -27,15 +27,11 @@ public TestConfiguration() this.BrowserOptions = new BrowserOptions(); } -#pragma warning disable CA1056 // Uri properties should not be strings - /// - /// Gets or sets the URL of the target Dynamics 365 instance. + /// Sets the URL of the target Dynamics 365 instance. /// [YamlMember(Alias = "url")] - public string Url { get; set; } - -#pragma warning restore CA1056 // Uri properties should not be strings + public string Url { private get; set; } /// /// Gets or sets the browser options to use for running tests. @@ -43,15 +39,17 @@ public TestConfiguration() [YamlMember(Alias = "browserOptions")] public BrowserOptions BrowserOptions { get; set; } -#pragma warning disable CA2227 // Collection properties should be read only - /// /// Gets or sets users that tests can be run as. /// [YamlMember(Alias = "users")] public List Users { get; set; } -#pragma warning restore CA2227 // Collection properties should be read only + /// + /// Gets or sets application user client ID and client secret for performing certain operations (e.g. impersonating other users during test data creation). + /// + [YamlMember(Alias = "applicationUser")] + public ClientCredentials ApplicationUser { get; set; } /// /// Gets the target URL. @@ -59,9 +57,7 @@ public TestConfiguration() /// The URL of the test environment. public Uri GetTestUrl() { - var urlEnvironmentVariable = Environment.GetEnvironmentVariable(this.Url); - - return string.IsNullOrEmpty(urlEnvironmentVariable) ? new Uri(this.Url) : new Uri(urlEnvironmentVariable); + return new Uri(ConfigHelper.GetEnvironmentVariableIfExists(this.Url)); } /// @@ -73,15 +69,7 @@ public UserConfiguration GetUser(string userAlias) { try { - var user = this.Users.First(u => u.Alias == userAlias); - - var usernameEnvironmentVariable = Environment.GetEnvironmentVariable(user.Username); - user.Username = string.IsNullOrEmpty(usernameEnvironmentVariable) ? user.Username : usernameEnvironmentVariable; - - var passwordEnvironmentVariable = Environment.GetEnvironmentVariable(user.Password); - user.Password = string.IsNullOrEmpty(passwordEnvironmentVariable) ? user.Password : passwordEnvironmentVariable; - - return user; + return this.Users.First(u => u.Alias == userAlias); } catch (Exception ex) { diff --git a/bindings/src/Capgemini.PowerApps.SpecFlowBindings/Configuration/UserConfiguration.cs b/bindings/src/Capgemini.PowerApps.SpecFlowBindings/Configuration/UserConfiguration.cs index 4bb3dee..bfcf7ed 100644 --- a/bindings/src/Capgemini.PowerApps.SpecFlowBindings/Configuration/UserConfiguration.cs +++ b/bindings/src/Capgemini.PowerApps.SpecFlowBindings/Configuration/UserConfiguration.cs @@ -7,17 +7,20 @@ /// public class UserConfiguration { + private string username; + private string password; + /// /// Gets or sets the username of the user. /// [YamlMember(Alias = "username")] - public string Username { get; set; } + public string Username { get => ConfigHelper.GetEnvironmentVariableIfExists(this.username); set => this.username = value; } /// /// Gets or sets the password of the user. /// [YamlMember(Alias = "password")] - public string Password { get; set; } + public string Password { get => ConfigHelper.GetEnvironmentVariableIfExists(this.password); set => this.password = value; } /// /// Gets or sets the alias of the user (used to retrieve from configuration). diff --git a/bindings/src/Capgemini.PowerApps.SpecFlowBindings/ITestDriver.cs b/bindings/src/Capgemini.PowerApps.SpecFlowBindings/ITestDriver.cs index bffa9b6..9e31a9a 100644 --- a/bindings/src/Capgemini.PowerApps.SpecFlowBindings/ITestDriver.cs +++ b/bindings/src/Capgemini.PowerApps.SpecFlowBindings/ITestDriver.cs @@ -7,6 +7,19 @@ /// public interface ITestDriver { + /// + /// Injects the driver onto the current page. + /// + /// The application user auth token (if configured). + void InjectOnPage(string authToken); + + /// + /// Loads scenario test data. + /// + /// The data to load. + /// The username of the user to impersonate. + void LoadTestDataAsUser(string data, string username); + /// /// Loads scenario test data. /// diff --git a/bindings/src/Capgemini.PowerApps.SpecFlowBindings/PowerAppsStepDefiner.cs b/bindings/src/Capgemini.PowerApps.SpecFlowBindings/PowerAppsStepDefiner.cs index e46feda..da78466 100644 --- a/bindings/src/Capgemini.PowerApps.SpecFlowBindings/PowerAppsStepDefiner.cs +++ b/bindings/src/Capgemini.PowerApps.SpecFlowBindings/PowerAppsStepDefiner.cs @@ -1,11 +1,12 @@ namespace Capgemini.PowerApps.SpecFlowBindings { using System; + using System.Configuration; using System.IO; using System.Reflection; using Capgemini.PowerApps.SpecFlowBindings.Configuration; using Microsoft.Dynamics365.UIAutomation.Api.UCI; - using Microsoft.Dynamics365.UIAutomation.Browser; + using Microsoft.Identity.Client; using OpenQA.Selenium; using YamlDotNet.Serialization; using YamlDotNet.Serialization.NamingConventions; @@ -17,6 +18,8 @@ public abstract class PowerAppsStepDefiner { private static TestConfiguration testConfig; + private static IConfidentialClientApplication app; + [ThreadStatic] private static ITestDriver testDriver; @@ -29,6 +32,21 @@ public abstract class PowerAppsStepDefiner [ThreadStatic] private static XrmApp xrmApp; + /// + /// Gets access token used to authenticate as the application user configured for testing. + /// + protected static string AccessToken + { + get + { + var hostSegments = TestConfig.GetTestUrl().Host.Split('.'); + + return GetApp().AcquireTokenForClient(new string[] { $"https://{hostSegments[0]}.api.{hostSegments[1]}.dynamics.com//.default" }) + .ExecuteAsync() + .Result.AccessToken; + } + } + /// /// Gets the configuration for the test project. /// @@ -82,7 +100,19 @@ protected static TestConfiguration TestConfig /// /// Gets provides utilities for test setup/teardown. /// - protected static ITestDriver TestDriver => testDriver ?? (testDriver = new TestDriver((IJavaScriptExecutor)Driver)); + protected static ITestDriver TestDriver + { + get + { + if (testDriver == null) + { + testDriver = new TestDriver((IJavaScriptExecutor)Driver); + testDriver.InjectOnPage(TestConfig.ApplicationUser != null ? AccessToken : null); + } + + return testDriver; + } + } /// /// Performs any cleanup necessary when quitting the WebBrowser. @@ -99,5 +129,26 @@ protected static void Quit() client = null; testDriver = null; } + + /// + /// Gets the used to authenticate as the configured application user. + /// + private static IConfidentialClientApplication GetApp() + { + if (TestConfig.ApplicationUser == null) + { + throw new ConfigurationErrorsException("An application user has not been configured."); + } + + if (app == null) + { + app = ConfidentialClientApplicationBuilder.Create(TestConfig.ApplicationUser.ClientId) + .WithTenantId(TestConfig.ApplicationUser.TenantId) + .WithClientSecret(TestConfig.ApplicationUser.ClientSecret) + .Build(); + } + + return app; + } } } diff --git a/bindings/src/Capgemini.PowerApps.SpecFlowBindings/Steps/DataSteps.cs b/bindings/src/Capgemini.PowerApps.SpecFlowBindings/Steps/DataSteps.cs index e77408d..0c593c1 100644 --- a/bindings/src/Capgemini.PowerApps.SpecFlowBindings/Steps/DataSteps.cs +++ b/bindings/src/Capgemini.PowerApps.SpecFlowBindings/Steps/DataSteps.cs @@ -1,7 +1,9 @@ namespace Capgemini.PowerApps.SpecFlowBindings.Steps { + using System.Configuration; using Capgemini.PowerApps.SpecFlowBindings; using Microsoft.Dynamics365.UIAutomation.Browser; + using Microsoft.Identity.Client; using TechTalk.SpecFlow; /// @@ -31,5 +33,18 @@ public static void GivenIHaveCreated(string fileName) { TestDriver.LoadTestData(TestDataRepository.GetTestData(fileName)); } + + /// + /// Creates a test record as a given user. + /// + /// The user alias. + /// The name of the file containing the test record. + [Given(@"'(.*)' has created '(.*)'")] + public static void GivenIHaveCreated(string alias, string fileName) + { + TestDriver.LoadTestDataAsUser( + TestDataRepository.GetTestData(fileName), + TestConfig.GetUser(alias).Username); + } } } diff --git a/bindings/src/Capgemini.PowerApps.SpecFlowBindings/TestDriver.cs b/bindings/src/Capgemini.PowerApps.SpecFlowBindings/TestDriver.cs index 113a8ad..8879378 100644 --- a/bindings/src/Capgemini.PowerApps.SpecFlowBindings/TestDriver.cs +++ b/bindings/src/Capgemini.PowerApps.SpecFlowBindings/TestDriver.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.IO; using System.Reflection; + using System.Text; using Microsoft.Xrm.Sdk; using OpenQA.Selenium; @@ -29,18 +30,48 @@ public class TestDriver : ITestDriver public TestDriver(IJavaScriptExecutor javascriptExecutor) { this.javascriptExecutor = javascriptExecutor; - - this.Initialise(); } private string FilePath => this.path ?? (this.path = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), DriverScriptPath)); + /// + public void InjectOnPage(string authToken) + { + var scriptBuilder = new StringBuilder(); + scriptBuilder.AppendLine(File.ReadAllText(this.FilePath)); + scriptBuilder.AppendLine($@"var recordRepository = new {LibraryNamespace}.CurrentUserRecordRepository(Xrm.WebApi.online); + var metadataRepository = new {LibraryNamespace}.MetadataRepository(Xrm.WebApi.online); + var deepInsertService = new {LibraryNamespace}.DeepInsertService(metadataRepository, recordRepository);"); + + if (!string.IsNullOrEmpty(authToken)) + { + scriptBuilder.AppendLine( + $@"var appUserRecordRepository = new {LibraryNamespace}.AuthenticatedRecordRepository(metadataRepository, '{authToken}'); + var dataManager = new {LibraryNamespace}.DataManager(recordRepository, deepInsertService, [new {LibraryNamespace}.FakerPreprocessor()], appUserRecordRepository);"); + } + else + { + scriptBuilder.AppendLine( + $"var dataManager = new {LibraryNamespace}.DataManager(recordRepository, deepInsertService, [new {LibraryNamespace}.FakerPreprocessor()]);"); + } + + scriptBuilder.AppendLine($"{TestDriverReference} = new {LibraryNamespace}.Driver(dataManager);"); + + this.javascriptExecutor.ExecuteScript(scriptBuilder.ToString()); + } + /// public void LoadTestData(string data) { this.ExecuteDriverFunctionAsync($"loadTestData(`{data}`)"); } + /// + public void LoadTestDataAsUser(string data, string username) + { + this.ExecuteDriverFunctionAsync($"loadTestDataAsUser(`{data}`, '{username}')"); + } + /// public void DeleteTestData() { @@ -82,16 +113,5 @@ private object ExecuteDriverFunctionAsync(string functionCall) return result; } - - private void Initialise() - { - this.javascriptExecutor.ExecuteScript( - $"{File.ReadAllText(this.FilePath)}\n" + - $@"var recordRepository = new {LibraryNamespace}.RecordRepository(Xrm.WebApi.online); - var metadataRepository = new {LibraryNamespace}.MetadataRepository(Xrm.WebApi.online); - var deepInsertService = new {LibraryNamespace}.DeepInsertService(metadataRepository, recordRepository); - var dataManager = new {LibraryNamespace}.DataManager(recordRepository, deepInsertService, [new {LibraryNamespace}.FakerPreprocessor()]); - {TestDriverReference} = new {LibraryNamespace}.Driver(dataManager);"); - } } } diff --git a/bindings/tests/Capgemini.PowerApps.SpecFlowBindings.UiTests/Data/a different team.json b/bindings/tests/Capgemini.PowerApps.SpecFlowBindings.UiTests/Data/a different team.json new file mode 100644 index 0000000..c4d8dd4 --- /dev/null +++ b/bindings/tests/Capgemini.PowerApps.SpecFlowBindings.UiTests/Data/a different team.json @@ -0,0 +1,5 @@ +{ + "@logicalName": "team", + "@alias": "the team", + "name": "A different team" +} \ No newline at end of file diff --git a/bindings/tests/Capgemini.PowerApps.SpecFlowBindings.UiTests/DataSteps.feature b/bindings/tests/Capgemini.PowerApps.SpecFlowBindings.UiTests/DataSteps.feature index 943e83f..8ee5a61 100644 --- a/bindings/tests/Capgemini.PowerApps.SpecFlowBindings.UiTests/DataSteps.feature +++ b/bindings/tests/Capgemini.PowerApps.SpecFlowBindings.UiTests/DataSteps.feature @@ -14,4 +14,7 @@ Scenario: Set a lookup with an alias Scenario: Generate data at run-time with faker And I have created 'data decorated with faker moustache syntax' - And I have opened 'the faked record' \ No newline at end of file + And I have opened 'the faked record' + +Scenario: Generate data as a named user + And 'an aliased user' has created 'a record with an alias' \ No newline at end of file diff --git a/bindings/tests/Capgemini.PowerApps.SpecFlowBindings.UiTests/DialogSteps.feature b/bindings/tests/Capgemini.PowerApps.SpecFlowBindings.UiTests/DialogSteps.feature index 09a4a34..61c32c1 100644 --- a/bindings/tests/Capgemini.PowerApps.SpecFlowBindings.UiTests/DialogSteps.feature +++ b/bindings/tests/Capgemini.PowerApps.SpecFlowBindings.UiTests/DialogSteps.feature @@ -25,9 +25,9 @@ Scenario: Assign to user on assign dialog And I assign to a user named 'Power Apps Checker Application' on the assign dialog Scenario: Assign to team on assign dialog - Given I have created 'a team' + Given I have created 'a different team' When I select the 'Assign' command - And I assign to a team named 'A team' on the assign dialog + And I assign to a team named 'A different team' on the assign dialog Scenario: Close warning dialog When I select the 'Show Error Dialog' command diff --git a/bindings/tests/Capgemini.PowerApps.SpecFlowBindings.UiTests/power-apps-bindings.yml b/bindings/tests/Capgemini.PowerApps.SpecFlowBindings.UiTests/power-apps-bindings.yml index ea91b15..ddaf357 100644 --- a/bindings/tests/Capgemini.PowerApps.SpecFlowBindings.UiTests/power-apps-bindings.yml +++ b/bindings/tests/Capgemini.PowerApps.SpecFlowBindings.UiTests/power-apps-bindings.yml @@ -5,7 +5,13 @@ browserOptions: width: 1920 height: 1080 startMaximized: false +applicationUser: + tenantId: POWERAPPS_SPECFLOW_BINDINGS_TEST_TENANTID + clientId: POWERAPPS_SPECFLOW_BINDINGS_TEST_CLIENTID + clientSecret: POWERAPPS_SPECFLOW_BINDINGS_TEST_CLIENTSECRET users: - username: POWERAPPS_SPECFLOW_BINDINGS_TEST_ADMIN_USERNAME password: POWERAPPS_SPECFLOW_BINDINGS_TEST_ADMIN_PASSWORD - alias: an admin \ No newline at end of file + alias: an admin + - username: POWERAPPS_SPECFLOW_BINDINGS_TEST_ADMIN_USERNAME + alias: an aliased user \ No newline at end of file diff --git a/driver/src/data/createOptions.ts b/driver/src/data/createOptions.ts new file mode 100644 index 0000000..922b6c0 --- /dev/null +++ b/driver/src/data/createOptions.ts @@ -0,0 +1,3 @@ +export interface CreateOptions { + userToImpersonate: string +} diff --git a/driver/src/data/dataManager.ts b/driver/src/data/dataManager.ts index 45f0a32..53c4dc2 100644 --- a/driver/src/data/dataManager.ts +++ b/driver/src/data/dataManager.ts @@ -1,7 +1,9 @@ -import { RecordRepository } from '../repositories'; +import AuthenticatedRecordRepository from '../repositories/authenticatedRecordRepository'; +import { CreateOptions } from './createOptions'; import DeepInsertService from './deepInsertService'; import Preprocessor from './preprocessor'; -import { Record } from './record'; +import Record from './record'; +import { CurrentUserRecordRepository } from '../repositories'; /** * Manages the creation and cleanup of data. @@ -14,7 +16,9 @@ export default class DataManager { public readonly refsByAlias: { [alias: string]: Xrm.LookupValue }; - private readonly recordRepo: RecordRepository; + private readonly currentUserRecordRepo: CurrentUserRecordRepository; + + private readonly appUserRecordRepo?: AuthenticatedRecordRepository; private readonly deepInsertSvc: DeepInsertService; @@ -22,34 +26,55 @@ export default class DataManager { /** * Creates an instance of DataManager. - * @param {RecordRepository} recordRepository A record repository. + * @param {RecordRepository} currentUserRecordRepo A record repository. * @param {DeepInsertService} deepInsertService A deep insert parser. + * @param {Preprocessor} preprocessors Preprocessors that modify test data before creation. + * @param {AuthenticatedRecordRepository} appUserRecordRepo An app user record repository * @memberof DataManager */ constructor( - recordRepository: RecordRepository, + currentUserRecordRepo: CurrentUserRecordRepository, deepInsertService: DeepInsertService, preprocessors?: Preprocessor[], + appUserRecordRepo?: AuthenticatedRecordRepository, ) { - this.refs = []; - this.refsByAlias = {}; - this.recordRepo = recordRepository; + this.currentUserRecordRepo = currentUserRecordRepo; this.deepInsertSvc = deepInsertService; + this.appUserRecordRepo = appUserRecordRepo; this.preprocessors = preprocessors; + + this.refs = []; + this.refsByAlias = {}; } /** * Deep inserts a record for use in a test. * + * @param {string} logicalName the logical name of the root entity. * @param {Record} record The record to deep insert. + * @param {CreateOptions} opts options for creating the data. * @returns {Promise} An entity reference to the root record. * @memberof DataManager */ - public async createData(logicalName: string, record: Record): Promise { + public async createData( + logicalName: string, + record: Record, + opts?: CreateOptions, + ): Promise { + if (opts?.userToImpersonate) { + if (!this.appUserRecordRepo) { + throw new Error('Unable to impersonate: an application user respository has not been configured.'); + } + this.appUserRecordRepo.setImpersonatedUserId( + await this.getObjectIdForUser(opts.userToImpersonate), + ); + } + const res = await this.deepInsertSvc.deepInsert( logicalName, this.preprocess(record), this.refsByAlias, + opts?.userToImpersonate ? this.appUserRecordRepo : this.currentUserRecordRepo, ); const newRecords = [res.record, ...res.associatedRecords]; @@ -64,20 +89,32 @@ export default class DataManager { return res.record.reference; } + private async getObjectIdForUser(username: string): Promise { + const res = await this.currentUserRecordRepo.retrieveMultipleRecords('systemuser', `?$filter=internalemailaddress eq '${username}'&$select=azureactivedirectoryobjectid`); + + if (res.entities.length === 0) { + throw new Error(`Unable to impersonate ${username} as the user was not found.`); + } + + return res.entities[0].azureactivedirectoryobjectid; + } + /** * Performs cleanup by deleting all records created via the TestDataManager. - * + * @param authToken An optional auth token to use when deleting test data. * @returns {Promise} * @memberof DataManager */ public async cleanup(): Promise<(Xrm.LookupValue | void)[]> { + const repo = this.appUserRecordRepo || this.currentUserRecordRepo; + const deletePromises = this.refs.map(async (record) => { let reference; let retry = 0; while (retry < 3) { try { // eslint-disable-next-line no-await-in-loop - reference = await this.recordRepo.deleteRecord(record); + reference = await repo.deleteRecord(record); break; } catch (err) { retry += 1; @@ -85,8 +122,10 @@ export default class DataManager { } return reference; }); + this.refs.splice(0, this.refs.length); Object.keys(this.refsByAlias).forEach((alias) => delete this.refsByAlias[alias]); + return Promise.all(deletePromises); } diff --git a/driver/src/data/deepInsertService.ts b/driver/src/data/deepInsertService.ts index 9ae4e9f..0f91145 100644 --- a/driver/src/data/deepInsertService.ts +++ b/driver/src/data/deepInsertService.ts @@ -1,6 +1,6 @@ import { MetadataRepository, RecordRepository } from '../repositories'; import { DeepInsertResponse } from './deepInsertResponse'; -import { Record } from './record'; +import Record from './record'; /** * Parses deep insert objects and returns references to all created records. @@ -29,6 +29,8 @@ export default class DeepInsertService { * * @param {string} logicalName The entity logical name of the root record. * @param {Record} record The deep insert object. + * @param dataByAlias References to previously created records by alias. + * @param {RecordRepository} repository An optional repository to override the default. * @returns {Promise} An async result with references to created records. * @memberof DeepInsertService */ @@ -36,7 +38,9 @@ export default class DeepInsertService { logicalName: string, record: Record, dataByAlias: { [alias: string]: Xrm.LookupValue }, + repository?: RecordRepository, ): Promise { + const repo = repository ?? this.recordRepository; const recordToCreate = record; const associatedRecords: { alias?: string, reference: Xrm.LookupValue }[] = []; @@ -65,11 +69,11 @@ export default class DeepInsertService { const collRecordsByNavProp = DeepInsertService.getOneToManyRecords(recordToCreate); Object.keys(collRecordsByNavProp).forEach((collNavProp) => delete recordToCreate[collNavProp]); - const recordToCreateRef = await this.recordRepository.upsertRecord(logicalName, recordToCreate); + const recordToCreateRef = await repo.upsertRecord(logicalName, recordToCreate); await Promise.all(Object.keys(collRecordsByNavProp).map(async (collNavProp) => { const result = await this.createCollectionRecords( - logicalName, recordToCreateRef, collRecordsByNavProp, collNavProp, dataByAlias, + logicalName, recordToCreateRef, collRecordsByNavProp, collNavProp, dataByAlias, repo, ); associatedRecords.push(...result); })); @@ -148,6 +152,7 @@ export default class DeepInsertService { navPropMap: { [navigationProperty: string]: Record[] }, collNavProp: string, refsByAlias: { [alias: string]: Xrm.LookupValue }, + repository: RecordRepository, ): Promise<{ alias?: string, reference: Xrm.LookupValue }[]> { const relMetadata = await this.metadataRepository.getRelationshipMetadata(collNavProp); const set = await this.metadataRepository.getEntitySetForEntity(logicalName); @@ -160,7 +165,15 @@ export default class DeepInsertService { const entity = relMetadata.Entity1LogicalName !== logicalName ? relMetadata.Entity1LogicalName : relMetadata.Entity2LogicalName; - return this.createManyToManyRecords(entity, collNavProp, navPropMap, parent, refsByAlias); + + return this.createManyToManyRecords( + entity, + collNavProp, + navPropMap, + parent, + refsByAlias, + repository, + ); } private async createOneToManyRecords( @@ -191,11 +204,12 @@ export default class DeepInsertService { navPropMap: { [navProp: string]: Record[] }, parent: Xrm.LookupValue, createdRecordsByAlias: { [alias: string]: Xrm.LookupValue }, + repository: RecordRepository, ): Promise<{ alias?: string, reference: Xrm.LookupValue }[]> { const result = await Promise.all(navPropMap[navProp].map(async (manyToManyRecord) => { const response = await this.deepInsert(entity, manyToManyRecord, createdRecordsByAlias); - await this.recordRepository.associateManyToManyRecords( + await repository.associateManyToManyRecords( parent, [response.record.reference], navProp, diff --git a/driver/src/data/fakerPreprocessor.ts b/driver/src/data/fakerPreprocessor.ts index 7ba2cef..0a4e1c1 100644 --- a/driver/src/data/fakerPreprocessor.ts +++ b/driver/src/data/fakerPreprocessor.ts @@ -1,6 +1,6 @@ import * as faker from 'faker'; import Preprocessor from './preprocessor'; -import { Record } from './record'; +import Record from './record'; export default class FakerPreprocessor extends Preprocessor { // eslint-disable-next-line class-methods-use-this diff --git a/driver/src/data/index.ts b/driver/src/data/index.ts index 839fe4b..1237fe8 100644 --- a/driver/src/data/index.ts +++ b/driver/src/data/index.ts @@ -1,7 +1,7 @@ export { default as DataManager } from './dataManager'; export { default as DeepInsertService } from './deepInsertService'; export { DeepInsertResponse } from './deepInsertResponse'; -export { Record } from './record'; +export { default as Record } from './record'; export { TestRecord } from './testRecord'; export { default as Preprocessor } from './preprocessor'; export { default as FakerPreprocessor } from './fakerPreprocessor'; diff --git a/driver/src/data/preprocessor.ts b/driver/src/data/preprocessor.ts index b1a98b7..fd6b7b4 100644 --- a/driver/src/data/preprocessor.ts +++ b/driver/src/data/preprocessor.ts @@ -1,4 +1,4 @@ -import { Record } from './record'; +import Record from './record'; export default abstract class Preprocessor { abstract preprocess(data: Record): Record; diff --git a/driver/src/data/record.ts b/driver/src/data/record.ts index e466d83..0e9323b 100644 --- a/driver/src/data/record.ts +++ b/driver/src/data/record.ts @@ -1,3 +1,3 @@ -export interface Record { +export default interface Record { [attribute: string]: number | string | unknown | unknown[]; } diff --git a/driver/src/data/testRecord.ts b/driver/src/data/testRecord.ts index 5bffa5a..a4d1d88 100644 --- a/driver/src/data/testRecord.ts +++ b/driver/src/data/testRecord.ts @@ -1,4 +1,4 @@ -import { Record } from './record'; +import Record from './record'; export interface TestRecord extends Record { '@alias': string; diff --git a/driver/src/driver.ts b/driver/src/driver.ts index ffd00f5..15a1a9d 100644 --- a/driver/src/driver.ts +++ b/driver/src/driver.ts @@ -29,12 +29,31 @@ export default class Driver { public async loadTestData(json: string): Promise { const testRecord = JSON.parse(json) as TestRecord; const logicalName = testRecord['@logicalName']; + return this.dataManager.createData(logicalName, testRecord); } + /** + * + * @param json a JSON object. + * @param userToImpersonate The username of the user to impersonate. + */ + public async loadTestDataAsUser( + json: string, + userToImpersonate: string, + ) { + if (!userToImpersonate) { + throw new Error('You have not provided the username of the user to impersonate.'); + } + + const testRecord = JSON.parse(json) as TestRecord; + const logicalName = testRecord['@logicalName']; + + return this.dataManager.createData(logicalName, testRecord, { userToImpersonate }); + } + /** * Deletes data that has been created as a result of any requests to load @see loadJsonData - * * @memberof Driver */ public deleteTestData(): Promise<(Xrm.LookupValue | void)[]> { diff --git a/driver/src/index.ts b/driver/src/index.ts index cca7e06..87f8ec2 100644 --- a/driver/src/index.ts +++ b/driver/src/index.ts @@ -1,3 +1,3 @@ export { default as Driver } from './driver'; export { DataManager, DeepInsertService, FakerPreprocessor } from './data'; -export { RecordRepository, MetadataRepository } from './repositories'; +export { CurrentUserRecordRepository, MetadataRepository, AuthenticatedRecordRepository } from './repositories'; diff --git a/driver/src/repositories/authenticatedRecordRepository.ts b/driver/src/repositories/authenticatedRecordRepository.ts new file mode 100644 index 0000000..ce4564f --- /dev/null +++ b/driver/src/repositories/authenticatedRecordRepository.ts @@ -0,0 +1,192 @@ +import MetadataRepository from './metadataRepository'; +import RecordRepository from './recordRepository'; +import Record from '../data/record'; + +/** + * Repository to handle CRUD operations for entities. + * + * @export + * @class RecordRepository + * @extends {Repository} + */ +export default class AuthenticatedRecordRepository implements RecordRepository { + private readonly headers: {[header:string]: string}; + + private readonly metadataRepo: MetadataRepository; + + /** + * Creates an instance of AuthenticatedRecordRepository. + * @param metadataRepo A metadata repository. + * @param authToken The auth token for the impersonating user. + * @param userToImpersonateId An optional ID for an impersonated user. + */ + constructor(metadataRepo: MetadataRepository, authToken: string, userToImpersonateId?: string) { + this.metadataRepo = metadataRepo; + + this.headers = { + Authorization: `Bearer ${authToken}`, + 'Content-Type': 'application/json', + }; + + if (userToImpersonateId) { + this.headers.CallerObjectId = userToImpersonateId; + } + } + + /** + * Sets the user to impersonate. + * @param userToImpersonateId The ID of the user to impersonate. + */ + public setImpersonatedUserId(userToImpersonateId: string) { + this.headers.CallerObjectId = userToImpersonateId; + } + + /** + * Retrieves a record. + * @param logicalName The logical name of the record to retrieve. + * @param id The ID of the record to retrieve. + * @param query The query string. + */ + public async retrieveRecord(logicalName: string, id: string, query?: string): Promise { + const entitySet = await this.metadataRepo.getEntitySetForEntity(logicalName); + const res = await fetch(`api/data/v9.1/${entitySet}(${id})${query}`, { headers: this.headers }); + + await AuthenticatedRecordRepository.checkResponseForError(res); + + return res.json(); + } + + /** + * Retrieves multiple records. + * @param logicalName The logical name of the records to retrieve. + * @param query The query string. + */ + public async retrieveMultipleRecords(logicalName: string, query?: string): Promise { + const entitySet = await this.metadataRepo.getEntitySetForEntity(logicalName); + const res = await fetch(`api/data/v9.1/${entitySet}${query}`, { headers: this.headers }); + + await AuthenticatedRecordRepository.checkResponseForError(res); + + return res.json(); + } + + /** + * Creates an entity record. + * + * @param {string} logicalName A logical name for the entity to create. + * @param {Record} record A record to create. + * @returns {Xrm.LookupValue} An entity reference to the created entity. + * @memberof RecordRepository + */ + public async createRecord(logicalName: string, record: Record): Promise { + const entitySet = await this.metadataRepo.getEntitySetForEntity(logicalName); + const res = await fetch(`api/data/v9.1/${entitySet}`, { + headers: this.headers, + body: JSON.stringify(record), + method: 'POST', + }); + + await AuthenticatedRecordRepository.checkResponseForError(res); + + const id = res.headers.get('OData-EntityId')!.match(/\((.*)\)/)![1]; + return { entityType: logicalName, id }; + } + + /** + * Upserts an entity record. + * @param {string} logicalName A logical name for the entity to upsert. + * @param {Record} record A record to upsert. + * @returns {Xrm.LookupValue} An entity reference to the upserted entity. + * @memberof RecordRepository + */ + public async upsertRecord(logicalName: string, record: Record): Promise { + if (!record['@key']) { + return this.createRecord(logicalName, record); + } + + const retrieveResponse = await this.retrieveMultipleRecords( + logicalName, + `?$filter=${record['@key']} eq '${record[record['@key'] as string]}'&$select=${logicalName}id`, + ); + + if (retrieveResponse.entities.length > 0) { + const id = retrieveResponse.entities[0][`${logicalName}id`]; + await this.updateRecord(logicalName, id, record); + + return { entityType: logicalName, id }; + } + + return this.createRecord(logicalName, record); + } + + /** + * Deletes an entity record. + * + * @param {Xrm.LookupValue} ref A reference to the entity to delete. + * @returns {Xrm.LookupValue} A reference to the deleted entity. + * @memberof RecordRepository + */ + public async deleteRecord(ref: Xrm.LookupValue): Promise { + const entitySet = await this.metadataRepo.getEntitySetForEntity(ref.entityType); + const res = await fetch(`api/data/v9.1/${entitySet}(${ref.id})`, + { + headers: this.headers, + method: 'DELETE', + }); + + await AuthenticatedRecordRepository.checkResponseForError(res); + + return ref; + } + + /** + * Associates two records in a N:N Relationship. + * + * @param {Xrm.LookupValue} primaryRecord The Primary Record to associate. + * @param {Xrm.LookupValue[]} relatedRecords The Related Records to associate. + * @param {string} relationship The N:N Relationship Name. + * @returns {Xrm.ExecuteResponse} The Response from the execute request. + * @memberof RecordRepository + */ + public async associateManyToManyRecords( + primaryRecord: Xrm.LookupValue, + relatedRecords: Xrm.LookupValue[], + relationship: string, + ): Promise { + const entitySetA = await this.metadataRepo.getEntitySetForEntity(primaryRecord.entityType); + const entitySetB = await this.metadataRepo.getEntitySetForEntity(relatedRecords[0].entityType); + + await Promise.all(relatedRecords.map(async (r) => { + const res = await fetch(`api/data/v9.1/${entitySetA}(${primaryRecord.id})/${relationship}/$ref`, + { + headers: this.headers, + body: JSON.stringify({ '@odata.id': `${entitySetB}(${r.id})` }), + method: 'POST', + }); + + await AuthenticatedRecordRepository.checkResponseForError(res); + })); + } + + private async updateRecord(logicalName: string, id: string, record: any) { + const entitySet = await this.metadataRepo.getEntitySetForEntity(logicalName); + const res = await fetch(`api/data/v9.1/${entitySet}(${id})`, + { + headers: this.headers, + body: JSON.stringify(record), + method: 'PATCH', + }); + + await AuthenticatedRecordRepository.checkResponseForError(res); + + return res.json(); + } + + // eslint-disable-next-line no-undef + private static async checkResponseForError(res: Response) { + if (res.status >= 400) { + const json = await res.json(); + throw new Error(`${json.error.code}: ${json.error.message}`); + } + } +} diff --git a/driver/src/repositories/currentUserRecordRepository.ts b/driver/src/repositories/currentUserRecordRepository.ts new file mode 100644 index 0000000..dc70c99 --- /dev/null +++ b/driver/src/repositories/currentUserRecordRepository.ts @@ -0,0 +1,111 @@ +import RecordRepository from './recordRepository'; +import { Record } from '../data'; +import { AssociateRequest } from '../requests'; + +/** + * Repository to handle CRUD operations for entities. + * + * @export + * @class RecordRepository + * @extends {Repository} + */ +export default class CurrentUserRecordRepository implements RecordRepository { + private readonly webApi: Xrm.WebApiOnline; + + /** + * Creates an instance of CurrentUserRecordRepository. + * @param webApi The web API instance. + */ + constructor(webApi: Xrm.WebApiOnline) { + this.webApi = webApi; + } + + /** + * Retrieves a record. + * @param logicalName The logical name of the record to retrieve. + * @param id The ID of the record to retrieve. + * @param query The query string. + */ + public async retrieveRecord(logicalName: string, id: string, query?: string): Promise { + return this.webApi.retrieveRecord(logicalName, id, query); + } + + /** + * Retrieves multiple records. + * @param logicalName The logical name of the records to retrieve. + * @param query The query string. + */ + public async retrieveMultipleRecords( + logicalName: string, + query: string, + ): Promise { + return this.webApi.retrieveMultipleRecords(logicalName, query); + } + + /** + * Creates an entity record. + * + * @param {string} logicalName A logical name for the entity to create. + * @param {Record} record A record to create. + * @returns {Xrm.LookupValue} An entity reference to the created entity. + * @memberof RecordRepository + */ + public async createRecord(logicalName: string, record: Record): Promise { + return this.webApi.createRecord(logicalName, record); + } + + /** + * Upserts an entity record. + * @param {string} logicalName A logical name for the entity to upsert. + * @param {Record} record A record to upsert. + * @returns {Xrm.LookupValue} An entity reference to the upserted entity. + * @memberof RecordRepository + */ + public async upsertRecord(logicalName: string, record: Record): Promise { + if (!record['@key']) { + return this.webApi.createRecord(logicalName, record); + } + + const retrieveResponse = await this.webApi.retrieveMultipleRecords( + logicalName, + `?$filter=${record['@key']} eq '${record[record['@key'] as string]}'&$select=${logicalName}id`, + ); + + if (retrieveResponse.entities.length > 0) { + const id = retrieveResponse.entities[0][`${logicalName}id`]; + await this.webApi.updateRecord(logicalName, id, record); + + return { entityType: logicalName, id }; + } + + return this.webApi.createRecord(logicalName, record); + } + + /** + * Deletes an entity record. + * + * @param {Xrm.LookupValue} ref A reference to the entity to delete. + * @returns {Xrm.LookupValue} A reference to the deleted entity. + * @memberof RecordRepository + */ + public async deleteRecord(ref: Xrm.LookupValue): Promise { + return this.webApi.deleteRecord(ref.entityType, ref.id) as unknown as Xrm.LookupValue; + } + + /** + * Associates two records in a N:N Relationship. + * + * @param {Xrm.LookupValue} primaryRecord The Primary Record to associate. + * @param {Xrm.LookupValue[]} relatedRecords The Related Records to associate. + * @param {string} relationship The N:N Relationship Name. + * @returns {Xrm.ExecuteResponse} The Response from the execute request. + * @memberof RecordRepository + */ + public async associateManyToManyRecords( + primaryRecord: Xrm.LookupValue, + relatedRecords: Xrm.LookupValue[], + relationship: string, + ): Promise { + this.webApi.execute(new AssociateRequest(primaryRecord, relatedRecords, relationship)); + } +} diff --git a/driver/src/repositories/index.ts b/driver/src/repositories/index.ts index c5c462b..be51924 100644 --- a/driver/src/repositories/index.ts +++ b/driver/src/repositories/index.ts @@ -1,3 +1,4 @@ export { default as MetadataRepository } from './metadataRepository'; export { default as RecordRepository } from './recordRepository'; -export { default as Repository } from './repository'; +export { default as CurrentUserRecordRepository } from './currentUserRecordRepository'; +export { default as AuthenticatedRecordRepository } from './authenticatedRecordRepository'; diff --git a/driver/src/repositories/metadataRepository.ts b/driver/src/repositories/metadataRepository.ts index 2cb2a3f..f1afbe0 100644 --- a/driver/src/repositories/metadataRepository.ts +++ b/driver/src/repositories/metadataRepository.ts @@ -1,5 +1,4 @@ /* eslint-disable class-methods-use-this */ -import Repository from './repository'; /** * Repository to handle requests for metadata. @@ -8,7 +7,7 @@ import Repository from './repository'; * @class MetadataRepository * @extends {Repository} */ -export default class MetadataRepository extends Repository { +export default class MetadataRepository { private static readonly RelationshipMetadataSet = 'RelationshipDefinitions'; private static readonly EntityMetadataSet = 'EntityDefinitions'; @@ -26,6 +25,7 @@ export default class MetadataRepository extends Repository { const response = await fetch( 'api/data/v9.1/' + `${MetadataRepository.EntityMetadataSet}?$filter=LogicalName eq '${logicalName}'&$select=EntitySetName`, + { cache: 'force-cache' }, ); const result = await response.json(); @@ -45,6 +45,7 @@ export default class MetadataRepository extends Repository { const response = await fetch( 'api/data/v9.1/' + `${MetadataRepository.EntityMetadataSet}(LogicalName='${logicalName}')/Attributes/Microsoft.Dynamics.CRM.LookupAttributeMetadata?$filter=LogicalName eq '${navigationProperty.toLowerCase()}'&$select=Targets`, + { cache: 'force-cache' }, ); const result = await response.json(); @@ -62,6 +63,7 @@ export default class MetadataRepository extends Repository { const response = await fetch( 'api/data/v9.1/' + `${MetadataRepository.OneToNMetadataSet}?$filter=ReferencedEntityNavigationPropertyName eq '${navPropName}'&$select=ReferencingEntityNavigationPropertyName`, + { cache: 'force-cache' }, ); const result = await response.json(); @@ -78,6 +80,7 @@ export default class MetadataRepository extends Repository { const response = await fetch( 'api/data/v9.1/' + `${MetadataRepository.RelationshipMetadataSet}(SchemaName='${relationshipSchemaName}')`, + { cache: 'force-cache' }, ); return response.json(); diff --git a/driver/src/repositories/recordRepository.ts b/driver/src/repositories/recordRepository.ts index 657184a..249fd7e 100644 --- a/driver/src/repositories/recordRepository.ts +++ b/driver/src/repositories/recordRepository.ts @@ -1,79 +1,14 @@ -import Repository from './repository'; -import { Record } from '../data/record'; -import { AssociateRequest } from '../requests/index'; - -/** - * Repository to handle CRUD operations for entities. - * - * @export - * @class RecordRepository - * @extends {Repository} - */ -export default class RecordRepository extends Repository { - /** - * Creates an entity record. - * - * @param {string} entityLogicalName A logical name for the entity to create. - * @param {Record} record A record to create. - * @returns {Xrm.LookupValue} An entity reference to the created entity. - * @memberof RecordRepository - */ - public async createRecord(entityLogicalName: string, record: Record): Promise { - return this.webApi.createRecord(entityLogicalName, record); - } - - /** - * Upserts an entity record. - * @param {string} entityLogicalName A logical name for the entity to upsert. - * @param {Record} record A record to upsert. - * @returns {Xrm.LookupValue} An entity reference to the upserted entity. - * @memberof RecordRepository - */ - public async upsertRecord(entityLogicalName: string, record: Record): Promise { - if (!record['@key']) { - return this.webApi.createRecord(entityLogicalName, record); - } - - const retrieveResponse = await this.webApi.retrieveMultipleRecords( - entityLogicalName, - `?$filter=${record['@key']} eq '${record[record['@key'] as string]}'&$select=${entityLogicalName}id`, - ); - - if (retrieveResponse.entities.length > 0) { - const id = retrieveResponse.entities[0][`${entityLogicalName}id`]; - await this.webApi.updateRecord(entityLogicalName, id, record); - - return { entityType: entityLogicalName, id }; - } - - return this.webApi.createRecord(entityLogicalName, record); - } - - /** - * Deletes an entity record. - * - * @param {Xrm.LookupValue} ref A reference to the entity to delete. - * @returns {Xrm.LookupValue} A reference to the deleted entity. - * @memberof RecordRepository - */ - public async deleteRecord(ref: Xrm.LookupValue): Promise { - return this.webApi.deleteRecord(ref.entityType, ref.id) as unknown as Xrm.LookupValue; - } - - /** - * Associates two records in a N:N Relationship. - * - * @param {Xrm.LookupValue} primaryRecord The Primary Record to associate. - * @param {Xrm.LookupValue[]} relatedRecords The Related Records to associate. - * @param {string} relationship The N:N Relationship Name. - * @returns {Xrm.ExecuteResponse} The Response from the execute request. - * @memberof RecordRepository - */ - public async associateManyToManyRecords( +import Record from '../data/record'; + +export default interface RecordRepository { + retrieveRecord(logicalName: string, id:string, query?: string): Promise; + retrieveMultipleRecords(logicalName: string, query?: string): Promise; + createRecord(logicalName: string, record: Record): Promise; + upsertRecord(logicalName: string, record: Record): Promise; + deleteRecord(ref: Xrm.LookupValue): Promise; + associateManyToManyRecords( primaryRecord: Xrm.LookupValue, relatedRecords: Xrm.LookupValue[], relationship: string, - ): Promise { - return this.webApi.execute(new AssociateRequest(primaryRecord, relatedRecords, relationship)); - } + ): Promise; } diff --git a/driver/src/repositories/repository.ts b/driver/src/repositories/repository.ts deleted file mode 100644 index 1ef8be6..0000000 --- a/driver/src/repositories/repository.ts +++ /dev/null @@ -1,7 +0,0 @@ -export default abstract class Repository { - protected readonly webApi: Xrm.WebApiOnline; - - constructor(webApi: Xrm.WebApiOnline) { - this.webApi = webApi; - } -} diff --git a/driver/test/data/dataManager.spec.ts b/driver/test/data/dataManager.spec.ts index 590d82b..3e97686 100644 --- a/driver/test/data/dataManager.spec.ts +++ b/driver/test/data/dataManager.spec.ts @@ -1,17 +1,21 @@ import { DataManager, DeepInsertService } from '../../src/data'; -import { RecordRepository } from '../../src/repositories'; +import { AuthenticatedRecordRepository, CurrentUserRecordRepository } from '../../src/repositories'; describe('TestDriver', () => { - let recordRepository: jasmine.SpyObj; + let currentUserRecordRepo: jasmine.SpyObj; + let appUserRecordRepo: jasmine.SpyObj; let deepInsertService: jasmine.SpyObj; let dataManager: DataManager; beforeEach(() => { - recordRepository = jasmine.createSpyObj( - 'RecordRepository', ['createRecord', 'deleteRecord'], + currentUserRecordRepo = jasmine.createSpyObj( + 'CurrentUserRecordRepository', ['createRecord', 'deleteRecord', 'retrieveMultipleRecords'], + ); + appUserRecordRepo = jasmine.createSpyObj( + 'AuthenticatedRecordRepository', ['createRecord', 'deleteRecord', 'setImpersonatedUserId'], ); deepInsertService = jasmine.createSpyObj('DeepInsertService', ['deepInsert']); - dataManager = new DataManager(recordRepository, deepInsertService); + dataManager = new DataManager(currentUserRecordRepo, deepInsertService, [], appUserRecordRepo); }); describe('.createData(record)', () => { @@ -45,6 +49,24 @@ describe('TestDriver', () => { expect(dataManager.refs).toEqual([rec.reference, ...assocRecs.map((r) => r.reference)]); }); + + it('uses the application user repository if a user to impersonate is passed', async () => { + const userToImpersonateId = 'user-id'; + currentUserRecordRepo.retrieveMultipleRecords.and.resolveTo( + { entities: [{ azureactivedirectoryobjectid: 'user-id' }], nextLink: '' }, + ); + const records: { alias?: string, reference: Xrm.LookupValue } = { + reference: { entityType: 'account', id: '' }, + }; + deepInsertService.deepInsert.and.resolveTo({ record: records, associatedRecords: [] }); + + await dataManager.createData('account', {}, { userToImpersonate: userToImpersonateId }); + + expect(appUserRecordRepo.setImpersonatedUserId).toHaveBeenCalledWith(userToImpersonateId); + expect(deepInsertService.deepInsert).toHaveBeenCalledWith( + jasmine.anything(), jasmine.anything(), jasmine.anything(), appUserRecordRepo, + ); + }); }); describe('.cleanup()', () => { @@ -69,29 +91,47 @@ describe('TestDriver', () => { await dataManager.cleanup(); expect( - recordRepository.deleteRecord.calls.allArgs().find((args) => args[0].id === rootRecord.id), + appUserRecordRepo.deleteRecord.calls.allArgs().find( + (args) => args[0].id === rootRecord.id, + ), ).not.toBeNull(); }); it('deletes associated records', async () => { await dataManager.cleanup(); - expect(recordRepository.deleteRecord.calls.count()).toBe(2); + expect(appUserRecordRepo.deleteRecord.calls.count()).toBe(2); }); it('does not attempt to delete previously deleted records', async () => { await dataManager.cleanup(); await dataManager.cleanup(); - expect(recordRepository.deleteRecord.calls.count()).toBe(2); + expect(appUserRecordRepo.deleteRecord.calls.count()).toBe(2); }); it('retries failed delete requests', async () => { - recordRepository.deleteRecord.and.throwError('Failed to delete'); + appUserRecordRepo.deleteRecord.and.throwError('Failed to delete'); dataManager.cleanup(); - expect(recordRepository.deleteRecord.calls.count()).toBe(6); + expect(appUserRecordRepo.deleteRecord.calls.count()).toBe(6); + }); + + it('uses the current user repository if no app user repository is set', async () => { + const dm = new DataManager(currentUserRecordRepo, deepInsertService); + const mockDeepInsertResponse = Promise.resolve( + { + associatedRecords: [{ reference: associatedRecord, alias: associatedRecordAlias }], + record: { reference: rootRecord, alias: rootRecordAlias }, + }, + ); + deepInsertService.deepInsert.and.returnValue(mockDeepInsertResponse); + await dm.createData('', {}); + + await dm.cleanup(); + + expect(currentUserRecordRepo.deleteRecord.calls.count()).toBe(2); }); }); }); diff --git a/driver/test/data/deepInsertService.spec.ts b/driver/test/data/deepInsertService.spec.ts index 22e72a0..f5ab978 100644 --- a/driver/test/data/deepInsertService.spec.ts +++ b/driver/test/data/deepInsertService.spec.ts @@ -229,5 +229,13 @@ describe('DeepInsertService', () => { expect(entityReference.record.reference).toBe(expectedEntityReference); }); + + it('overrides the default repository with the one passed as an argument (if provided)', async () => { + const newRecordRepo = jasmine.createSpyObj('RecordRepository', ['upsertRecord']); + + await deepInsertService.deepInsert('account', { name: 'Sample Account' }, {}, newRecordRepo); + + expect(newRecordRepo.upsertRecord).toHaveBeenCalledTimes(1); + }); }); }); diff --git a/driver/test/driver.spec.ts b/driver/test/driver.spec.ts index 6984437..3aabc0b 100644 --- a/driver/test/driver.spec.ts +++ b/driver/test/driver.spec.ts @@ -1,3 +1,4 @@ +import { CreateOptions } from '../src/data/createOptions'; import DataManager from '../src/data/dataManager'; import Driver from '../src/driver'; @@ -9,6 +10,23 @@ describe('Driver', () => { driver = new Driver(dataManager); }); + describe('.loadTestDataAsUser(json, username)', () => { + it('throws if a username isn\'t provided', () => expectAsync(driver.loadTestDataAsUser('{}', '')).toBeRejectedWithError(/.*username.*/)); + + it('passes CreateOptions to dataManager', async () => { + const userToImpersonate = 'user@contoso.com'; + const expectedCreateOptions: CreateOptions = { userToImpersonate }; + + await driver.loadTestDataAsUser('{ "@logicalName": "contact" }', userToImpersonate); + + expect(dataManager.createData).toHaveBeenCalledWith( + jasmine.anything(), + jasmine.anything(), + jasmine.objectContaining(expectedCreateOptions), + ); + }); + }); + describe('.loadJsonData(json)', () => { it('uses the TestDataManager to create the test data', () => { const logicalName = 'account'; diff --git a/driver/test/repositories/authenticatedUserRecordRepository.spec.ts b/driver/test/repositories/authenticatedUserRecordRepository.spec.ts new file mode 100644 index 0000000..f753f3e --- /dev/null +++ b/driver/test/repositories/authenticatedUserRecordRepository.spec.ts @@ -0,0 +1,139 @@ +import { AuthenticatedRecordRepository, MetadataRepository } from '../../src/repositories/index'; + +const fetchMock = require('fetch-mock/es5/client'); + +describe('CurrentUserRecordRepository', () => { + const logicalName = 'account'; + + let recordRepo: AuthenticatedRecordRepository; + let metadataRepo: jasmine.SpyObj; + beforeEach(() => { + metadataRepo = jasmine.createSpyObj('MetadataRepository', + [ + 'getEntitySetForEntity', + 'getEntityForLookupProperty', + 'getLookupPropertyForCollectionProperty', + 'getRelationshipMetadata', + ]); + metadataRepo.getEntitySetForEntity.and.returnValues(Promise.resolve('accounts'), Promise.resolve('contacts')); + recordRepo = new AuthenticatedRecordRepository(metadataRepo, 'token', '41416194-ee8e-4e6a-9b7a-4a76c4fd9cc7'); + }); + + afterEach(() => { + fetchMock.restore(); + }); + + describe('setImpersonatedUserId(userToImpersonateId)', () => { + it('updates the CallerObjectId header of requests', async () => { + const guid = 'bc148b3f-d7d7-4546-a3a8-d92ffd9a98c8'; + fetchMock.mock('*', { headers: { 'OData-EntityId': `api/data/v9.1/accounts(${guid})` } }); + recordRepo.setImpersonatedUserId(guid); + + await recordRepo.createRecord(logicalName, {}); + + expect(fetchMock.calls()[0][1].headers.CallerObjectId).toBe(guid); + }); + }); + + describe('createRecord(entityLogicalName, record)', () => { + it('returns a reference to the created record', async () => { + const guid = 'bc148b3f-d7d7-4546-a3a8-d92ffd9a98c8'; + fetchMock.mock('*', { headers: { 'OData-EntityId': `api/data/v9.1/accounts(${guid})` } }); + + const actualReference = await recordRepo.createRecord(logicalName, { }); + + expect(actualReference).toEqual({ entityType: logicalName, id: guid }); + }); + }); + + describe('retrieveRecord(entityLogicalName, id, query)', () => { + it('returns the retrieved record', async () => { + const expectedRecord = { accountid: 'account-id' }; + fetchMock.mock('*', expectedRecord); + + const actualRecord = await recordRepo.retrieveRecord('account', 'account-id'); + + expect(actualRecord).toEqual(expectedRecord); + }); + }); + + describe('retrieveMultipleRecord(entityLogicalName, query)', () => { + it('returns the retrieved records', async () => { + const expectedResponse: Xrm.RetrieveMultipleResult = { entities: [], nextLink: '' }; + fetchMock.mock('*', expectedResponse); + + const actualResponse = await recordRepo.retrieveMultipleRecords('account', '?'); + + expect(actualResponse).toEqual(expectedResponse); + }); + }); + + describe('upsertRecord(entityLogicalName, record)', () => { + const recordWithKey = { '@key': 'keyfield', keyfield: 'Test Key' }; + + it('performs an update when a match is found on @key', async () => { + const matchedRecordId = ''; + const retrieveResult: Xrm.RetrieveMultipleResult = { + entities: [{ accountid: matchedRecordId }], + nextLink: '', + }; + fetchMock.mock('*', retrieveResult); + + const result = await recordRepo.upsertRecord(logicalName, recordWithKey); + + expect(result.id).toBe(matchedRecordId); + }); + + it('performs a create when no match is found on @key', async () => { + const retrieveResult: Xrm.RetrieveMultipleResult = { + entities: [], + nextLink: '', + }; + const id = 'account-id'; + fetchMock.get('*', retrieveResult); + fetchMock.post('*', { headers: { 'OData-EntityId': `api/data/v9.1/accounts(${id})` } }); + + const result = await recordRepo.upsertRecord(logicalName, recordWithKey); + + expect(result.id).toBe(id); + }); + + it('performs a create when no @key is specified', async () => { + const id = 'account-id'; + fetchMock.post('*', { headers: { 'OData-EntityId': `api/data/v9.1/accounts(${id})` } }); + + const result = await recordRepo.upsertRecord(logicalName, {}); + + expect(result.id).toBe(id); + }); + }); + + describe('deleteRecord(entityReference)', () => { + it('returns a reference to the deleted record', async () => { + const entityReference: Xrm.LookupValue = { + entityType: 'account', + id: '', + }; + fetchMock.mock('*', { status: 200 }); + + const result = await recordRepo.deleteRecord(entityReference); + + expect(result).toEqual(entityReference); + }); + }); + + describe('associateManyToManyRecords(primaryRecord, relatedRecord, relationshipName)', () => { + it('executes an associate request', async () => { + const primary: Xrm.LookupValue = { id: '', entityType: 'primaryentity' }; + const related: Xrm.LookupValue[] = [{ id: '', entityType: 'relatedentity' }]; + const relationship = 'primary_related'; + fetchMock.mock('*', { status: 200 }); + + await recordRepo.associateManyToManyRecords(primary, related, relationship); + const call = fetchMock.calls()[0]; + + expect(call[0]).toBe('/api/data/v9.1/accounts(%3Cprimary-id%3E)/primary_related/$ref'); + expect(call[1].body).toBe('{"@odata.id":"contacts()"}'); + }); + }); +}); diff --git a/driver/test/repositories/currentUserRecordRepository.spec.ts b/driver/test/repositories/currentUserRecordRepository.spec.ts new file mode 100644 index 0000000..d6bac46 --- /dev/null +++ b/driver/test/repositories/currentUserRecordRepository.spec.ts @@ -0,0 +1,129 @@ +import { CurrentUserRecordRepository, RecordRepository } from '../../src/repositories/index'; +import { AssociateRequest } from '../../src/requests'; + +describe('CurrentUserRecordRepository', () => { + let xrmWebApi: jasmine.SpyObj; + let recordRepository: RecordRepository; + beforeEach(() => { + xrmWebApi = jasmine.createSpyObj('XrmWebApi', ['createRecord', 'deleteRecord', 'execute', 'retrieveMultipleRecords', 'updateRecord', 'retrieveRecord']); + recordRepository = new CurrentUserRecordRepository(xrmWebApi); + }); + + describe('createRecord(entityLogicalName, record)', () => { + it('returns a reference to the created record', async () => { + const expectedReference: Xrm.CreateResponse = { entityType: 'account', id: '' }; + xrmWebApi.createRecord.and.returnValue(Promise.resolve(expectedReference) as never); + + const actualReference = await recordRepository.createRecord('account', { name: 'Test Account' }); + + expect(actualReference).toBe(expectedReference); + }); + }); + + describe('retrieveRecord(entityLogicalName, id, query)', () => { + it('returns the retrieved record', async () => { + const expectedRecord = {}; + xrmWebApi.retrieveRecord.and.returnValue(Promise.resolve(expectedRecord) as never); + + const actualRecord = await recordRepository.retrieveRecord('account', 'account-id'); + + expect(actualRecord).toBe(expectedRecord); + }); + + it('passes the expected arguments to retrieveRecords', async () => { + const logicalName = 'account'; + const id = 'id'; + const query = '?$select=accountid'; + + await recordRepository.retrieveRecord(logicalName, id, query); + + expect(xrmWebApi.retrieveRecord).toHaveBeenCalledWith(logicalName, id, query); + }); + }); + + describe('retrieveMultipleRecord(entityLogicalName, query)', () => { + it('returns the retrieved records', async () => { + const expectedResponse: Xrm.RetrieveMultipleResult = { entities: [], nextLink: '' }; + xrmWebApi.retrieveMultipleRecords.and.returnValue(Promise.resolve(expectedResponse) as never); + + const actualResposne = await recordRepository.retrieveMultipleRecords('account', '?'); + + expect(actualResposne).toBe(expectedResponse); + }); + + it('passes the expected arguments to retrieveMultipleRecords', async () => { + const logicalName = 'account'; + const query = '?$select=accountid'; + + await recordRepository.retrieveMultipleRecords(logicalName, query); + + expect(xrmWebApi.retrieveMultipleRecords).toHaveBeenCalledWith(logicalName, query); + }); + }); + + describe('upsertRecord(entityLogicalName, record)', () => { + const recordWithkey = { '@key': 'keyfield', keyfield: 'Test Key' }; + + it('performs an update when a match is found on @key', async () => { + const logicalName = 'account'; + const matchedRecordId = ''; + const retrieveResult: Xrm.RetrieveMultipleResult = { + entities: [{ accountid: matchedRecordId }], + nextLink: '', + }; + xrmWebApi.retrieveMultipleRecords.and.returnValue(Promise.resolve(retrieveResult) as never); + + await recordRepository.upsertRecord(logicalName, recordWithkey); + + expect(xrmWebApi.updateRecord) + .toHaveBeenCalledWith(logicalName, matchedRecordId, recordWithkey); + }); + + it('performs a create when no match is found on @key', async () => { + const logicalName = 'account'; + xrmWebApi.retrieveMultipleRecords.and.returnValue(Promise.resolve({ entities: [], nextLink: '' }) as never); + + await recordRepository.upsertRecord(logicalName, recordWithkey); + + expect(xrmWebApi.createRecord).toHaveBeenCalledWith(logicalName, recordWithkey); + }); + + it('performs a create when no @key is specified', async () => { + const logicalName = 'account'; + const recordWithoutKey = {}; + await recordRepository.upsertRecord(logicalName, recordWithoutKey); + + expect(xrmWebApi.createRecord).toHaveBeenCalledWith(logicalName, recordWithoutKey); + }); + }); + + describe('deleteRecord(entityReference)', () => { + it('returns a reference to the deleted record', async () => { + const entityReference: Xrm.LookupValue = { + entityType: 'account', + id: '', + }; + xrmWebApi.deleteRecord.and.returnValue(Promise.resolve(entityReference) as never); + + const result = await recordRepository.deleteRecord(entityReference); + + expect(result).toBe(entityReference); + }); + }); + + describe('associateManyToManyRecords(primaryRecord, relatedRecord, relationshipName)', () => { + it('executes an associate request', async () => { + const primary: Xrm.LookupValue = { id: '', entityType: 'primaryentity' }; + const related: Xrm.LookupValue[] = [{ id: '', entityType: 'relatedentity' }]; + const relationship = 'primary_related'; + + await recordRepository.associateManyToManyRecords(primary, related, relationship); + + const actualRequest = xrmWebApi.execute.calls.first().args[0] as AssociateRequest; + + expect(actualRequest.target).toBe(primary); + expect(actualRequest.relatedEntities).toBe(related); + expect(actualRequest.relationship).toBe(relationship); + }); + }); +}); diff --git a/driver/test/repositories/metadataRepository.spec.ts b/driver/test/repositories/metadataRepository.spec.ts index fe02361..d9b3e25 100644 --- a/driver/test/repositories/metadataRepository.spec.ts +++ b/driver/test/repositories/metadataRepository.spec.ts @@ -3,13 +3,14 @@ import { MetadataRepository } from '../../src/repositories'; const fetchMock = require('fetch-mock/es5/client'); describe('MetadataRepository', () => { - let xrmWebApi: jasmine.SpyObj; let metadataRepo: MetadataRepository; beforeEach(() => { - xrmWebApi = jasmine.createSpyObj('XrmWebApi', ['createRecord', 'deleteRecord', 'execute', 'retrieveMultipleRecords', 'updateRecord']); - metadataRepo = new MetadataRepository(xrmWebApi); - fetchMock.reset(); + metadataRepo = new MetadataRepository(); + }); + + afterEach(() => { + fetchMock.restore(); }); describe('getEntitySetForEntity(logicalName)', () => { @@ -53,7 +54,7 @@ describe('MetadataRepository', () => { it('returns relationship metadata for the provided relationship schema name', async () => { const result = {}; const relationshipSchemaName = 'contact_accounts'; - fetchMock.mock(/.*RelationshipDefinitions(SchemaName='relationshipSchemaName')/, { body: result, sendAsJson: true }); + fetchMock.mock('*', { body: result, sendAsJson: true }); expectAsync(metadataRepo.getRelationshipMetadata(relationshipSchemaName)) .toBeResolvedTo(result as never); diff --git a/driver/test/repositories/recordRepository.spec.ts b/driver/test/repositories/recordRepository.spec.ts deleted file mode 100644 index d1b77f4..0000000 --- a/driver/test/repositories/recordRepository.spec.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { RecordRepository } from '../../src/repositories/index'; -import { AssociateRequest } from '../../src/requests'; - -describe('RecordRepository', () => { - let xrmWebApi: jasmine.SpyObj; - let recordRepository: RecordRepository; - beforeEach(() => { - xrmWebApi = jasmine.createSpyObj('XrmWebApi', ['createRecord', 'deleteRecord', 'execute', 'retrieveMultipleRecords', 'updateRecord']); - recordRepository = new RecordRepository(xrmWebApi); - }); - - describe('createRecord(entityLogicalName, record)', () => { - it('returns a reference to the created record', async () => { - const expectedReference: Xrm.CreateResponse = { entityType: 'account', id: '' }; - xrmWebApi.createRecord.and.returnValue(Promise.resolve(expectedReference) as never); - - const actualReference = await recordRepository.createRecord('account', { name: 'Test Account' }); - - expect(actualReference).toBe(expectedReference); - }); - }); - - describe('upsertRecord(entityLogicalName, record)', () => { - const record = { '@key': 'keyfield', keyfield: 'Test Key' }; - - it('performs an update when a match is found on @key', async () => { - const logicalName = 'account'; - const matchedRecordId = ''; - const retrieveResult: Xrm.RetrieveMultipleResult = { - entities: [{ accountid: matchedRecordId }], - nextLink: '', - }; - xrmWebApi.retrieveMultipleRecords.and.returnValue(Promise.resolve(retrieveResult) as never); - - await recordRepository.upsertRecord(logicalName, record); - - expect(xrmWebApi.updateRecord).toHaveBeenCalledWith(logicalName, matchedRecordId, record); - }); - - it('performs a create when no match is found on @key', async () => { - const logicalName = 'account'; - xrmWebApi.retrieveMultipleRecords.and.returnValue(Promise.resolve({ entities: [], nextLink: '' }) as never); - - await recordRepository.upsertRecord(logicalName, record); - - expect(xrmWebApi.createRecord).toHaveBeenCalledWith(logicalName, record); - }); - - describe('deleteRecord(entityReference)', () => { - it('returns a reference to the deleted record', async () => { - const entityReference: Xrm.LookupValue = { - entityType: 'account', - id: '', - }; - xrmWebApi.deleteRecord.and.returnValue(Promise.resolve(entityReference) as never); - - const result = await recordRepository.deleteRecord(entityReference); - - expect(result).toBe(entityReference); - }); - }); - - describe('associateManyToManyRecords(primaryRecord, relatedRecord, relationshipName)', () => { - it('executes an associate request', async () => { - const primary: Xrm.LookupValue = { id: '', entityType: 'primaryentity' }; - const related: Xrm.LookupValue[] = [{ id: '', entityType: 'relatedentity' }]; - const relationship = 'primary_related'; - - await recordRepository.associateManyToManyRecords(primary, related, relationship); - - const actualRequest = xrmWebApi.execute.calls.first().args[0] as AssociateRequest; - - expect(actualRequest.target).toBe(primary); - expect(actualRequest.relatedEntities).toBe(related); - expect(actualRequest.relationship).toBe(relationship); - }); - }); - }); -}); diff --git a/driver/tsconfig.json b/driver/tsconfig.json index 84327c1..2ac2aec 100644 --- a/driver/tsconfig.json +++ b/driver/tsconfig.json @@ -7,10 +7,10 @@ "strict": true, "sourceMap": true, "outDir": "dist", - "allowSyntheticDefaultImports": true + "allowSyntheticDefaultImports": true, }, "include": [ "src", - "test" - ] + "test", + ], } \ No newline at end of file diff --git a/templates/include-build-and-test-steps.yml b/templates/include-build-and-test-steps.yml index f0af842..6d8cadf 100644 --- a/templates/include-build-and-test-steps.yml +++ b/templates/include-build-and-test-steps.yml @@ -86,8 +86,10 @@ jobs: searchFolder: bindings\tests\Capgemini.PowerApps.SpecFlowBindings.UiTests rerunFailedTests: true rerunMaxAttempts: 2 - continueOnError: true env: + POWERAPPS_SPECFLOW_BINDINGS_TEST_TENANTID: $(Application User Tenant ID) + POWERAPPS_SPECFLOW_BINDINGS_TEST_CLIENTID: $(Application User Client ID) + POWERAPPS_SPECFLOW_BINDINGS_TEST_CLIENTSECRET: $(Application User Client Secret) POWERAPPS_SPECFLOW_BINDINGS_TEST_ADMIN_USERNAME: $(User ADO Integration Username) POWERAPPS_SPECFLOW_BINDINGS_TEST_ADMIN_PASSWORD: $(User ADO Integration Password) POWERAPPS_SPECFLOW_BINDINGS_TEST_URL: $(URL)