diff --git a/CHANGELOG.md b/CHANGELOG.md index 673b8b3..d61f1c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,42 @@ All notable changes to UGS CLI will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## [1.1.0] - 2023-10-12 + +### Added +* Bash installer to download and install the UGS CLI on MacOS and Linux +* Added config as code support for economy module + * Deploy + * Fetch +* Added config as code support for access module + * Deploy + * Fetch +* Added `new-file` commands for economy resources + * For inventory items + * For currencies + * For virtual purchases + * For real-item purchases + * For Cloud Code C# Modules + * For project access policies + * For triggers +* Added `gsh server files` command behind feature flag +* Added support for .sln files on deploy + * .sln files now are compiled and zipped into .ccm before deploying +* Added config as code support for triggers + * Deploy + +### Changed +- Services can support multiple file extensions +- Updated server states in `ugs gsh machine list` + +### Fixed +- Handle exceptions when using Deploy with a Remote Config file that has unsupported config types. +- Fixed an issue where if a leaderboard fails to load, it incorrectly deploys as a empty leaderboard and it is not reported +- Added correct description when Cloud Code deploy has duplication file error during dry-run. +- Fixed an issue with `ugs gsh fleet-region update` not ensuring the fleet region is brought online by default. +- Handle exception for mis-spelt bool input params for `ugs gsh fleet-region update` command. +- Fixed an issue with Deploy and Fetch on Remote Config containing JSON arrays. + ## [1.0.0] - 2023-08-01 ### Added @@ -15,12 +51,17 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Deploy sends file configurations into the service - Fetch updates local files based on service configuration - Leaderboards now supports `new-file`, to create an empty file for leaderboards +- Added new commands to Game Server Hosting for: machine list ### Changed - Removed Leaderboards support to `create` and `update` commands. ### Fixed +- GSH Fleet Region Update now properly reports offline fleet region values. +- GSH Fleet Region server settings now align with UDash - A bug logging an additional error when deploying a file. +- A bug preventing Remote Config deploy from printing entries when encountering a `RemoteConfigDeploymentException`. +- A bug in Cloud Code scripts throwing unhandled exception when using create command with a directory path but an empty file name. ## [1.0.0-beta.6] - 2023-07-10 diff --git a/README.md b/README.md index abfe3b1..6b4066e 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,13 @@ # UGS CLI -For installing the UGS CLI and getting started, see [Getting Started](https://services.docs.unity.com/guides/ugs-cli/latest/general/get-started/install-the-cli). +The unity gaming services (UGS) CLI is a unified command line interface tool for gaming services. The source code project is for reference only. You may not be able to build it due to lack of access to internal dependencies. -## Installation - -### With npm -Install the CLI with npm by calling `npm install -g ugs` in your command line. - -### With GitHub -Download the executable directly from the [GitHub releases](https://github.com/Unity-Technologies/unity-gaming-services-cli/releases). - -On macos and linux, use `chmod +x ` to mark the file as executable. +## Installation and Getting Started +To get started and install the CLI, see [Getting Started]. ## Documentation -To see the full list of services and commands available in the UGS CLI, visit the documentation on https://services.docs.unity.com/guides/ugs-cli/latest/general/overview +To see the full list of services and commands available in the UGS CLI, visit the [documentation]. ## Basic Commands An UGS CLI command has the following format: @@ -55,3 +48,5 @@ Please [Submit a Request](https://support.unity.com/hc/en-us/requests/new?ticket [Alpine]: https://alpinelinux.org/ [Ubuntu]: https://ubuntu.com/ [Windows]: https://www.microsoft.com/windows/ +[Getting Started]: https://services.docs.unity.com/guides/ugs-cli/latest/general/get-started/install-the-cli +[Documentation]: https://services.docs.unity.com/guides/ugs-cli/latest/general/overview diff --git a/Samples/Deploy/Access/sample-policy.ac b/Samples/Deploy/Access/sample-policy.ac new file mode 100644 index 0000000..00d6ef2 --- /dev/null +++ b/Samples/Deploy/Access/sample-policy.ac @@ -0,0 +1,15 @@ +{ + "$schema": "https://ugs-config-schemas.unity3d.com/v1/project-access-policy.schema.json", + "Statements": [ + { + "Sid": "DenyAccessToAllServices", + "Action": [ + "*" + ], + "Effect": "Allow", + "Principal": "Player", + "Resource": "urn:ugs:*", + "Version": "1.0.0" + } + ] +} \ No newline at end of file diff --git a/Samples/Deploy/CloudCode/Module/Module.ccm b/Samples/Deploy/CloudCode/Module/Module.ccm new file mode 100644 index 0000000..da8a157 Binary files /dev/null and b/Samples/Deploy/CloudCode/Module/Module.ccm differ diff --git a/Samples/Deploy/CloudCode/Script.js b/Samples/Deploy/CloudCode/Script/Script.js similarity index 100% rename from Samples/Deploy/CloudCode/Script.js rename to Samples/Deploy/CloudCode/Script/Script.js diff --git a/Samples/Deploy/Economy/CURRENCY.ecc b/Samples/Deploy/Economy/CURRENCY.ecc new file mode 100644 index 0000000..8038bad --- /dev/null +++ b/Samples/Deploy/Economy/CURRENCY.ecc @@ -0,0 +1,4 @@ +{ + "initial": 3333, + "name": "currency" +} diff --git a/Samples/Deploy/Economy/INVENTORY_ITEM.eci b/Samples/Deploy/Economy/INVENTORY_ITEM.eci new file mode 100644 index 0000000..51735e9 --- /dev/null +++ b/Samples/Deploy/Economy/INVENTORY_ITEM.eci @@ -0,0 +1,3 @@ +{ + "name": "inventory item" +} diff --git a/Samples/Deploy/Economy/REAL_MONEY_PURCHASE.ecr b/Samples/Deploy/Economy/REAL_MONEY_PURCHASE.ecr new file mode 100644 index 0000000..7675e5d --- /dev/null +++ b/Samples/Deploy/Economy/REAL_MONEY_PURCHASE.ecr @@ -0,0 +1,12 @@ +{ + "storeIdentifiers": { + "googlePlayStore": "123" + }, + "rewards": [ + { + "resourceId": "CURRENCY", + "amount": 6 + } + ], + "name": "My Real Money Purchase" +} diff --git a/Samples/Deploy/Economy/VIRTUAL_MONEY_PURCHASE.ecv b/Samples/Deploy/Economy/VIRTUAL_MONEY_PURCHASE.ecv new file mode 100644 index 0000000..7466cc4 --- /dev/null +++ b/Samples/Deploy/Economy/VIRTUAL_MONEY_PURCHASE.ecv @@ -0,0 +1,15 @@ +{ + "costs": [ + { + "resourceId": "CURRENCY", + "amount": 2 + } + ], + "rewards": [ + { + "resourceId": "INVENTORY_ITEM", + "amount": 6 + } + ], + "name": "My Virtual Purchase" +} diff --git a/Samples/Deploy/Leaderboards/lbsample.lb b/Samples/Deploy/Leaderboards/lbsample.lb new file mode 100644 index 0000000..6b83dc3 --- /dev/null +++ b/Samples/Deploy/Leaderboards/lbsample.lb @@ -0,0 +1,26 @@ +{ + "$schema": "https://ugs-config-schemas.unity3d.com/v1/leaderboards.schema.json", + "SortOrder": "asc", + "UpdateType": "keepBest", + "Name": "My Leaderboard", + "ResetConfig": { + "Start": "2023-08-25T00:00:00-04:00", + "Schedule": "0 12 1 * *" + }, + "TieringConfig": { + "Strategy": "score", + "Tiers": [ + { + "Id": "Gold", + "Cutoff": 200.0 + }, + { + "Id": "Silver", + "Cutoff": 100.0 + }, + { + "Id": "Bronze" + } + ] + } +} \ No newline at end of file diff --git a/Samples/Deploy/Triggers/my-triggers.tr b/Samples/Deploy/Triggers/my-triggers.tr new file mode 100644 index 0000000..5ecd880 --- /dev/null +++ b/Samples/Deploy/Triggers/my-triggers.tr @@ -0,0 +1,16 @@ +{ + "Configs": [ + { + "Name": "Trigger 1", + "EventType": "EventType1", + "ActionType": "cloud-code", + "ActionUrn": "urn:ugs:cloud-code:MyScript" + }, + { + "Name": "Trigger 2", + "EventType": "EventType2", + "ActionType": "cloud-code", + "ActionUrn": "urn:ugs:cloud-code:MyModule/MyFunction" + } + ] +} diff --git a/Samples/Deploy/instructions.md b/Samples/Deploy/instructions.md index dd2a31e..aa3cbaa 100644 --- a/Samples/Deploy/instructions.md +++ b/Samples/Deploy/instructions.md @@ -8,14 +8,17 @@ [Deploy Cloud Code Script](#deploy-cloud-code-script)
[Deploy Remote Config](#deploy-remote-config)
+[Deploy Economy](#deploy-economy)
+[Deploy Leaderboards](#deploy-leaderboards)
+[Deploy Access](#deploy-access)
## Deploy Cloud Code Script Run command from [Samples/Deploy] directory: ``` -ugs deploy ./CloudCode +ugs deploy ./CloudCode/Script ``` -You will find [Script.js] published in your dashboard for the configured project and environment. The command deploys supported contents in `CloudCode` directory. +You will find [Script.js] published in your dashboard for the configured project and environment. The command deploys supported contents in `CloudCode/Script` directory. ### Create Cloud Code Script @@ -33,6 +36,20 @@ module.exports.params = { ``` Please take [Script.js] as an example. For more details, please check [Declare parameters in the script]. +## Deploy Cloud Code Module + +Run command from [Samples/Deploy] directory: +``` +ugs deploy ./CloudCode/Module +``` +You will find [Module.ccm] published in your dashboard for the configured project and environment. The command deploys supported contents in `CloudCode/Module` directory. + +### Create Cloud Code Module + +To create a deployable cloud code module, you need either a `.ccm` file or a `.sln`. When deploying a `.sln` file the entry point project will be determined by the publish profile. + +Please take [Module.ccm] as an example. For more details, please check [Create a Module Project] or [Create a Module Project using the CLI]. + ## Deploy Remote Config Run command from [Samples/Deploy] directory: @@ -49,7 +66,7 @@ To create a deployable remote config file, you need a `.rc` file with the follow "$schema": "https://ugs-config-schemas.unity3d.com/v1/remote-config.schema.json", "entries": { "your-unique-config-key": 100.2 - } + }, "types" : { "your-unique-config-key": "FLOAT" } @@ -63,24 +80,102 @@ Run command from [Samples/Deploy] directory: ``` ugs deploy ./Economy ``` -You will find the resource from [resource.ec] published in your dashboard for the configured project and environment. -### Create Economy Files: +You will find the resource from [CURRENCY.ecc], [INVENTORY_ITEM.eci], [VIRTUAL_PURCHASE.ecv] and [REAL_MONEY_PURCHASE.ecr] published in your dashboard for the configured project and environment. + +### Create Economy Files +There are 4 file formats for Economy: +- `.ecc` for Currency +- `.eci` for Inventory Item +- `.ecr` for Real Money Purchase +- `.ecv` for Virtual Purchase -To create a deployable economy file, you need a `.ec` file with the following pattern: +All of the files, regardless of type, should contain a json containing the required information for the specific Economy resource. You can find out what information to put in each file by looking at the [Economy resource schemas]. + +Some fields may be omitted from the resource file, such as `type` (inferred by file extension), `id` (defaults to be equal to name), `customData` and other optional fields. + +### File Content Examples +File: GOLD.ecc ```Json { - "id": "GOLD", - "name": "Gold", - "type": "CURRENCY", + "name": "GOLD", "initial": 10, - "max": 1000, - "customData": null + "max": 1000 +} +``` + +Check out examples for [CURRENCY.ecc], [INVENTORY_ITEM.eci], [VIRTUAL_PURCHASE.ecv] and [REAL_MONEY_PURCHASE.ecr]. + +## Deploy Leaderboards + +Run command from [Samples/Deploy] directory: +``` +ugs deploy ./Leaderboards +``` +You will find the resource from [lbsample.lb] published in your dashboard for the configured project and environment. + +### Create Leaderboards Files: + +To create a deployable leaderboards file, you need a `.lb` file with the following pattern: +```Json +{ + "$schema": "https://ugs-config-schemas.unity3d.com/v1/leaderboards.schema.json", + "SortOrder": "asc", + "UpdateType": "keepBest", + "Name": "My Leaderboard", + "ResetConfig": { + "Start": "2023-08-25T00:00:00-04:00", + "Schedule": "0 12 1 * *" + }, + "TieringConfig": { + "Strategy": "score", + "Tiers": [ + { + "Id": "Gold", + "Cutoff": 200.0 + }, + { + "Id": "Silver", + "Cutoff": 100.0 + }, + { + "Id": "Bronze" + } + ] + } } ``` -Please take [resource.ec] as an example. There are other patterns for Inventory item, virtual and real money purchase, for more details, please check [Economy resource schemas]. +Please take [lbsample.lb] as an example. For more details, please check [Leaderboards API] and/or [Leaderboards schema]. +## Deploy Access +Run command from [Samples/Deploy] directory: +``` +ugs deploy ./Access +``` +You will find the resource from [sample-policy.ac] published in your dashboard for the configured project and environment. + +### Create Access Files: + +To create a deployable access file, you need a `.ac` file with the following pattern: +```Json +{ + "$schema": "https://ugs-config-schemas.unity3d.com/v1/project-access-policy.schema.json", + "Statements": [ + { + "Sid": "DenyAccessToAllServices", + "Action": [ + "*" + ], + "Effect": "Allow", + "Principal": "Player", + "Resource": "urn:ugs:*", + "Version": "1.0.0" + } + ] +} +``` +Please take [sample-policy.ac] as an example. For more details, please check [Access Control Documentation Portal] and/or [Access Control schema]. ## Deploy all Samples Run command from [Samples/Deploy] directory: ``` @@ -88,12 +183,43 @@ ugs deploy . ``` You will find all the contents deployed in your dashboard for the configured project and environment. The command deploys all the samples in current (`Deploy`) directory for supported services. + +----------------------------------- +## Deploy Triggers + +Run command from [Samples/Deploy] directory: +``` +ugs deploy ./Triggers +``` +You will find the resources from [my-triggers.tr] published to your specified environment. + +### Create Triggers Files: + +To create a deployable trigger file, you need a `.tr` file. +The template can be created from the CLI using `ugs triggers new-file`. + +Additionally, [my-triggers.tr] as an example. +For more details, please check [Triggers Documentation Portal] and/or [Triggers Schema]. + +## Deploy all Samples +Run command from [Samples/Deploy] directory: +``` +ugs deploy . +``` +You will find all the contents deployed in your dashboard for the configured project and environment. The command deploys all the samples in current (`Deploy`) directory for supported services. + + --- +[Create a Module Project using the CLI]: https://docs.unity.com/ugs/en-us/manual/cloud-code/manual/modules/how-to-guides/write-modules/cli +[Create a Module Project]: https://docs.unity.com/ugs/en-us/manual/cloud-code/manual/modules/getting-started [`deploy`]: https://services.docs.unity.com/guides/ugs-cli/latest/general/base-commands/deploy [Remote Config files]: https://docs.unity3d.com/Packages/com.unity.remote-config@3.3/manual/Authoring/remote_config_files.html +[Leaderboards API]: https://services.docs.unity.com/leaderboards-admin/ +[Leaderboards schema]: https://ugs-config-schemas.unity3d.com/v1/leaderboards.schema.json [Economy resource schemas]: https://services.docs.unity.com/economy-admin/v2#tag/Economy-Admin/operation/addConfigResource [Declare parameters in the script]: https://docs.unity.com/cloud-code/authoring-scripts-editor.html#Declare_parameters_in_the_script -[Script.js]: /Samples/Deploy/CloudCode/Script.js +[Module.ccm]: /Samples/Deploy/CloudCode/Module/Module.ccm +[Script.js]: /Samples/Deploy/CloudCode/Script/Script.js [configuration.rc]: /Samples/Deploy/RemoteConfig/configuration.rc [resource.ec]: /Samples/Deploy/Economy/resource.ec [Samples/Deploy]: /Samples/Deploy @@ -101,3 +227,13 @@ You will find all the contents deployed in your dashboard for the configured pro [Service Account]: https://services.docs.unity.com/docs/service-account-auth/index.html [Login]: https://services.docs.unity.com/guides/ugs-cli/latest/general/base-commands/authentication/login [configuration]: https://services.docs.unity.com/guides/ugs-cli/latest/general/base-commands/configuration/configuration-keys +[CURRENCY.ecc]: /Samples/Deploy/Economy/CURRENCY.ecc +[INVENTORY_ITEM.eci]: /Samples/Deploy/Economy/INVENTORY_ITEM.eci +[VIRTUAL_PURCHASE.ecv]: /Samples/Deploy/Economy/VIRTUAL_MONEY_PURCHASE.ecv +[REAL_MONEY_PURCHASE.ecr]: /Samples/Deploy/Economy/REAL_MONEY_PURCHASE.ecr +[Access Control Documentation Portal]: https://docs.unity.com/ugs/en-us/manual/overview/manual/access-control +[Access Control schema]: https://ugs-config-schemas.unity3d.com/v1/project-access-policy.schema.json +[sample-policy.ac]: /Samples/Deploy/ProjectAccess/sample-policy.ac +[Triggers Documentation Portal]: https://docs.unity.com/ugs/en-us/manual/cloud-code/manual/triggers +[Triggers Schema]: https://ugs-config-schemas.unity3d.com/v1/triggers.schema.json +[my-triggers.tr]: /Samples/Deploy/Triggers/my-triggers.tr diff --git a/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/Deploy/IProjectAccessDeploymentHandler.cs b/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/Deploy/IProjectAccessDeploymentHandler.cs new file mode 100644 index 0000000..17e47e4 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/Deploy/IProjectAccessDeploymentHandler.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Unity.Services.Access.Authoring.Core.Model; +using Unity.Services.Access.Authoring.Core.Results; + +namespace Unity.Services.Access.Authoring.Core.Deploy +{ + public interface IProjectAccessDeploymentHandler + { + Task DeployAsync( + IReadOnlyList files, + bool dryRun = false, + bool reconcile = false); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/Deploy/ProjectAccessDeploymentHandler.cs b/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/Deploy/ProjectAccessDeploymentHandler.cs new file mode 100644 index 0000000..d0aa3b3 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/Deploy/ProjectAccessDeploymentHandler.cs @@ -0,0 +1,326 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Unity.Services.Access.Authoring.Core.ErrorHandling; +using Unity.Services.DeploymentApi.Editor; +using Unity.Services.Access.Authoring.Core.Model; +using Unity.Services.Access.Authoring.Core.Results; +using Unity.Services.Access.Authoring.Core.Service; +using Unity.Services.Access.Authoring.Core.Validations; + +namespace Unity.Services.Access.Authoring.Core.Deploy +{ + public class ProjectAccessDeploymentHandler : IProjectAccessDeploymentHandler + { + readonly IProjectAccessClient m_ProjectAccessClient; + readonly IProjectAccessConfigValidator m_ProjectAccessConfigValidator; + readonly IProjectAccessMerger m_ProjectAccessMerger; + + public ProjectAccessDeploymentHandler( + IProjectAccessClient projectAccessClient, + IProjectAccessConfigValidator projectAccessConfigValidator, + IProjectAccessMerger projectAccessMerger) + { + m_ProjectAccessClient = projectAccessClient; + m_ProjectAccessConfigValidator = projectAccessConfigValidator; + m_ProjectAccessMerger = projectAccessMerger; + } + + public async Task DeployAsync( + IReadOnlyList files, + bool dryRun = false, + bool reconcile = false) + { + var res = new DeployResult(); + + if (!dryRun) + { + SetStartDeployingStatus(files); + } + + var deploymentExceptions = new List(); + + var validProjectAccessFiles = files + .Where((t, i) => m_ProjectAccessConfigValidator.Validate(t, deploymentExceptions)) + .ToList(); + + var validLocalStatements = m_ProjectAccessConfigValidator.FilterNonDuplicatedAuthoringStatements( + validProjectAccessFiles, + deploymentExceptions); + + var serverProjectAccessPolicyResult = await GetServerProjectAccessPolicies(files, dryRun); + var remoteStatements = serverProjectAccessPolicyResult ?? new List(); + + var localSet = validLocalStatements.Select(l => l.Sid).ToHashSet(); + var remoteSet = remoteStatements.Select(l => l.Sid).ToHashSet(); + + var toUpdate = FindStatementsToUpdate(remoteSet, validLocalStatements); + var toDelete = FindStatementsToDelete(remoteStatements, localSet, reconcile); + var toCreate = FindStatementsToCreate(remoteSet, validLocalStatements); + + var toDeploy = m_ProjectAccessMerger.MergeStatementsToDeploy( + toCreate, + toUpdate, + toDelete, + remoteStatements); + + var failedFiles = FindFailedFiles(deploymentExceptions); + var filesToDeploy = FindFilesToDeploy(files, toDeploy); + var deployedFiles = filesToDeploy.Except(failedFiles).ToList(); + + res.Created = toCreate; + res.Deleted = toDelete; + res.Updated = toUpdate; + res.Deployed = deployedFiles; + res.Failed = failedFiles; + + if (dryRun) + { + SetStatuses(res); + return res; + } + + try + { + if (reconcile && toDelete.Count != 0) + { + await m_ProjectAccessClient.DeleteAsync(toDelete.ToList()); + } + + await m_ProjectAccessClient.UpsertAsync(toDeploy.ToList()); + + SetSuccessfulDeployStatuses(filesToDeploy, res, remoteStatements); + } + catch (ProjectAccessPolicyDeploymentException e) + { + deploymentExceptions.Add(e); + e.AffectedFiles.AddRange(filesToDeploy); + res.Failed = FindFailedFiles(deploymentExceptions); + res.Deployed = FindFilesToDeploy(files, toDeploy).Except(res.Failed).ToList(); + } + catch (Exception e) + { + SetFailedStatus(filesToDeploy, DeploymentStatus.FailedToDeploy.Message, e.Message); + res.Failed = filesToDeploy; + res.Deployed = Array.Empty(); + } + + HandleDeploymentException(deploymentExceptions); + return res; + } + + static void SetStatuses(DeployResult res) + { + SetStatus( + res.Created, + "Created", + string.Empty, + SeverityLevel.Info); + SetStatus( + res.Updated, + "Updated", + string.Empty, + SeverityLevel.Info); + SetStatus( + res.Deleted, + "Deleted", + string.Empty, + SeverityLevel.Info); + } + + static void SetSuccessfulDeployStatuses(List filesToDeploy, DeployResult res, List remoteStatements) + { + foreach (var f in filesToDeploy) + f.Status = new DeploymentStatus("Deployed", "Deployed Successfully"); + + SetStatus( + res.Created, + "Created", + string.Empty, + SeverityLevel.Info); + SetStatus( + res.Updated.Where(s => s.HasStatementChanged(remoteStatements)).ToList(), + "Updated", + string.Empty, + SeverityLevel.Info); + SetStatus( + res.Updated.Where(s => !s.HasStatementChanged(remoteStatements)).ToList(), + "Updated", + "Statement was unchanged", + SeverityLevel.Info); + SetStatus( + res.Deleted, + "Deleted", + string.Empty, + SeverityLevel.Info); + } + + static List FindStatementsToUpdate( + HashSet remoteSet, + IReadOnlyList local) + { + var toUpdate = local + .Where(k => remoteSet.Contains(k.Sid)) + .ToList(); + + return toUpdate; + } + + static List FindStatementsToCreate( + HashSet remoteSet, + IReadOnlyList local) + { + return local + .Where(k => !remoteSet.Contains(k.Sid)) + .ToList(); + } + + static List FindStatementsToDelete( + IReadOnlyList remote, + HashSet localSet, + bool reconcile) + { + if (!reconcile) + { + return new List(); + } + + var toDelete = remote + .Where(r => !localSet.Contains(r.Sid)) + .ToList(); + + return toDelete; + } + + static List FindFilesToDeploy( + IReadOnlyList files, + IReadOnlyList toDeploy) + { + return files + .Where(file => file.Statements.Any(toDeploy.Contains)) + .ToList(); + } + + static List FindFailedFiles( + List deploymentExceptions) + { + var failed = new List(); + + foreach (var exception in deploymentExceptions) + { + failed.AddRange(exception.AffectedFiles); + } + + return failed.Distinct().ToList(); + } + + async Task> GetServerProjectAccessPolicies( + IReadOnlyList configFiles, + bool dryRun) + { + try + { + return await m_ProjectAccessClient.GetAsync(); + } + catch (Exception e) + { + if (!dryRun) + SetFailedStatus(configFiles, detail: e.Message); + throw; + } + } + + void HandleDeploymentException(ICollection deploymentExceptions) + { + if (!deploymentExceptions.Any()) + { + return; + } + + foreach (var deploymentException in deploymentExceptions) + { + SetFailedStatus( + deploymentException.AffectedFiles, + deploymentException.StatusDescription, + deploymentException.StatusDetail); + } + } + + void SetFailedStatus(IReadOnlyList files, string status = null, string detail = null) + { + foreach (var projectAccessFile in files) + { + projectAccessFile.Status = new DeploymentStatus(status, detail, SeverityLevel.Error); + } + + SetStatusAndProgress( + files, + status ?? "Failed to deploy", + detail ?? " Unknown Error", + SeverityLevel.Error, + 0f); + } + + void SetStatusAndProgress( + IReadOnlyList files, + string status, + string detail, + SeverityLevel severityLevel, + float progress) + { + foreach (var file in files) + { + UpdateStatus( + file, + status, + detail, + severityLevel); + UpdateProgress(file, progress); + } + } + + protected virtual void UpdateStatus( + IProjectAccessFile projectAccessFile, + string status, + string detail, + SeverityLevel severityLevel) + { + projectAccessFile + .Statements + .ForEach(s => s.Status = new DeploymentStatus(status, detail, severityLevel)); + } + + protected virtual void UpdateProgress( + IProjectAccessFile projectAccessFile, + float progress) + { + projectAccessFile + .Statements + .ForEach(s => s.Progress = progress); + } + + + void SetStartDeployingStatus(IReadOnlyList files) + { + SetStatusAndProgress( + files, + string.Empty, + string.Empty, + SeverityLevel.None, + 0f); + } + + static void SetStatus( + IReadOnlyList items, + string status, + string detail, + SeverityLevel severityLevel) + { + foreach (var file in items) + { + file.Status = new DeploymentStatus(status, detail, severityLevel); + } + } + } +} diff --git a/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/ErrorHandling/DuplicateAuthoringStatementsException.cs b/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/ErrorHandling/DuplicateAuthoringStatementsException.cs new file mode 100644 index 0000000..3fa8c8c --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/ErrorHandling/DuplicateAuthoringStatementsException.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +using System.Runtime.Serialization; +using System.Text; +using Unity.Services.Access.Authoring.Core.Model; + +namespace Unity.Services.Access.Authoring.Core.ErrorHandling +{ + [Serializable] + public class DuplicateAuthoringStatementsException : ProjectAccessPolicyDeploymentException + { + readonly string m_Sid; + + public override string Message => $"{StatusDescription} {StatusDetail}"; + + public override string StatusDescription => "Duplicate Sid in files."; + public override StatusLevel Level => StatusLevel.Error; + + public override string StatusDetail + { + get + { + var builder = new StringBuilder(); + builder.Append($"Multiple resources with the same identifier '{m_Sid}' were found. "); + builder.Append("Only a single resource for a given identifier may be deployed/fetched at the same time. "); + builder.Append("Give all resources unique identifiers or deploy/fetch them separately to proceed.\n"); + + foreach (var file in AffectedFiles) + { + builder.Append($" '{file.Path}'"); + } + + return builder.ToString(); + } + } + + public DuplicateAuthoringStatementsException(string sid, IReadOnlyList files) + { + m_Sid = sid; + AffectedFiles = new List(files); + } + + protected DuplicateAuthoringStatementsException(SerializationInfo info, StreamingContext context) + : base(info, context) + { } + } +} diff --git a/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/ErrorHandling/InvalidDataException.cs b/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/ErrorHandling/InvalidDataException.cs new file mode 100644 index 0000000..44b64f2 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/ErrorHandling/InvalidDataException.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Generic; +using System.Runtime.Serialization; +using Unity.Services.Access.Authoring.Core.Model; + +namespace Unity.Services.Access.Authoring.Core.ErrorHandling +{ + [Serializable] + public class InvalidDataException : ProjectAccessPolicyDeploymentException + { + readonly IProjectAccessFile m_File; + readonly string m_ErrorMessage; + + public override string Message => $"{StatusDescription} {StatusDetail}"; + + + public override string StatusDescription => "Invalid Data."; + + public override string StatusDetail => $"The file {m_File.Name} contains Invalid Data: {m_ErrorMessage}"; + public override StatusLevel Level => StatusLevel.Error; + + public InvalidDataException(IProjectAccessFile file, string errorMessage) + { + m_File = file; + m_ErrorMessage = errorMessage; + AffectedFiles = new List {file}; + } + + protected InvalidDataException(SerializationInfo info, StreamingContext context) + : base(info, context) + { } + } +} diff --git a/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/ErrorHandling/ProjectAccessPolicyDeploymentException.cs b/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/ErrorHandling/ProjectAccessPolicyDeploymentException.cs new file mode 100644 index 0000000..89737c1 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/ErrorHandling/ProjectAccessPolicyDeploymentException.cs @@ -0,0 +1,27 @@ +using System; +using System.Collections.Generic; +using System.Runtime.Serialization; +using Unity.Services.Access.Authoring.Core.Model; + +namespace Unity.Services.Access.Authoring.Core.ErrorHandling +{ + public abstract class ProjectAccessPolicyDeploymentException : Exception + { + public List AffectedFiles { get; protected set; } + public abstract string StatusDescription { get; } + public abstract string StatusDetail { get; } + public abstract StatusLevel Level { get; } + + public enum StatusLevel + { + Error, + Warning + } + protected ProjectAccessPolicyDeploymentException() + { } + + protected ProjectAccessPolicyDeploymentException(SerializationInfo info, StreamingContext context) + : base(info, context) + { } + } +} diff --git a/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/Fetch/IProjectAccessFetchHandler.cs b/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/Fetch/IProjectAccessFetchHandler.cs new file mode 100644 index 0000000..b5bc2db --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/Fetch/IProjectAccessFetchHandler.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Unity.Services.Access.Authoring.Core.Model; +using Unity.Services.Access.Authoring.Core.Results; + +namespace Unity.Services.Access.Authoring.Core.Fetch +{ + public interface IProjectAccessFetchHandler + { + public Task FetchAsync( + string rootDirectory, + IReadOnlyList files, + bool dryRun = false, + bool reconcile = false, + CancellationToken token = default); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/Fetch/ProjectAccessFetchHandler.cs b/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/Fetch/ProjectAccessFetchHandler.cs new file mode 100644 index 0000000..23da944 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/Fetch/ProjectAccessFetchHandler.cs @@ -0,0 +1,260 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Unity.Services.Access.Authoring.Core.ErrorHandling; +using Unity.Services.Access.Authoring.Core.IO; +using Unity.Services.Access.Authoring.Core.Json; +using Unity.Services.Access.Authoring.Core.Model; +using Unity.Services.Access.Authoring.Core.Results; +using Unity.Services.Access.Authoring.Core.Service; +using Unity.Services.Access.Authoring.Core.Validations; +using Unity.Services.DeploymentApi.Editor; + +namespace Unity.Services.Access.Authoring.Core.Fetch +{ + public class ProjectAccessFetchHandler : IProjectAccessFetchHandler + { + internal const string FetchResultName = "project-statements.ac"; + readonly IProjectAccessClient m_Client; + readonly IFileSystem m_FileSystem; + readonly IJsonConverter m_JsonConverter; + readonly IProjectAccessConfigValidator m_ProjectAccessValidator; + + public ProjectAccessFetchHandler( + IProjectAccessClient client, + IFileSystem fileSystem, + IJsonConverter jsonConverter, + IProjectAccessConfigValidator projectAccessValidator) + { + m_Client = client; + m_FileSystem = fileSystem; + m_JsonConverter = jsonConverter; + m_ProjectAccessValidator = projectAccessValidator; + } + + public async Task FetchAsync( + string rootDirectory, + IReadOnlyList files, + bool dryRun = false, + bool reconcile = false, + CancellationToken token = default) + { + var remote = await m_Client.GetAsync(); + + var fetchExceptions = new List(); + + var validLocal = + m_ProjectAccessValidator.FilterNonDuplicatedAuthoringStatements(files, fetchExceptions); + + if (fetchExceptions.Count > 0) + { + await HandleFetchExceptions(fetchExceptions); + } + + var localSet = validLocal.Select(l => l.Sid).ToHashSet(); + var remoteSet = remote.Select(l => l.Sid).ToHashSet(); + + var toUpdate = FindEntriesToUpdate(remote, localSet); + var toDelete = FindEntriesToDelete(remoteSet, validLocal); + var toCreate = FindEntriesToCreate(remote, localSet, reconcile); + var toFetch = files.ToList(); + + if (dryRun) + { + var res = new FetchResult( + toCreate, + toUpdate, + toDelete, + toFetch); + UpdateStatus(toFetch, toDelete, toUpdate, toCreate); + return res; + } + + UpdateLocal(files, toUpdate); + DeleteLocal(files, toDelete); + + var defaultFile = GetDefaultFile(rootDirectory, toCreate, files); + + await WriteOrDeleteFiles(files); + + if (reconcile && toCreate.Count > 0) + { + await WriteOrDeleteFiles( + new[] + { + defaultFile + }); + + if (!toFetch.Contains(defaultFile)) + toFetch.Add(defaultFile); + } + + UpdateStatus(toFetch, toDelete, toUpdate, toCreate); + foreach (var file in toFetch) + { + file.Status = new DeploymentStatus("Deployed", string.Empty, SeverityLevel.Success); + } + return new FetchResult( + toCreate, + toUpdate, + toDelete, + toFetch); + } + + static IProjectAccessFile GetDefaultFile( + string rootDirectory, + IReadOnlyList toCreate, + IReadOnlyList files) + { + var defaultFile = files.FirstOrDefault(f => f.Name == FetchResultName); + + if (defaultFile == null) + { + var filePath = Path.GetFullPath(Path.Combine(rootDirectory, FetchResultName)); + var file = new ProjectAccessFile() + { + Name = Path.GetFileName(filePath), + Path = filePath, + }; + + foreach (var statement in toCreate) + { + statement.Path = filePath; + } + file.Statements = (List)toCreate; + + defaultFile = file; + } + else + { + defaultFile.UpdateOrCreateStatements(toCreate); + } + + return defaultFile; + } + + static List FindEntriesToUpdate( + IReadOnlyList remote, + HashSet localSet) + { + var toUpdate = remote + .Where(r => localSet.Contains(r.Sid)) + .ToList(); + + return toUpdate; + } + + static List FindEntriesToDelete( + HashSet remote, + IReadOnlyList local) + { + var toDelete = local + .Where(l => !remote.Contains(l.Sid)) + .ToList(); + + return toDelete; + } + + static List FindEntriesToCreate( + IReadOnlyList remote, + HashSet localSet, + bool reconcile) + { + if (!reconcile) + return new List(); + + return remote + .Where(k => !localSet.Contains(k.Sid)) + .ToList(); + } + + static void UpdateLocal( + IReadOnlyList files, + IReadOnlyList remote) + { + foreach (var file in files) + { + file.UpdateStatements(remote); + } + } + + static void DeleteLocal( + IReadOnlyList files, + IReadOnlyList toDelete) + { + foreach (var file in files.Where(file => file.Statements.Any())) + { + file.RemoveStatements(toDelete); + } + } + + async Task WriteOrDeleteFiles( + IReadOnlyList files) + { + var tasks = new List(files.Count); + + foreach (var file in files) + { + if (file.Statements.Any()) + { + var content = new ProjectAccessFileContent(file.Statements); + + var text = m_JsonConverter.SerializeObject(content); + + tasks.Add(m_FileSystem.WriteAllText(file.Path, text)); + } + else + { + tasks.Add(m_FileSystem.Delete(file.Path)); + } + } + + await Task.WhenAll(tasks); + } + + static Task HandleFetchExceptions(List fetchExceptions) + { + var exceptions = fetchExceptions + .SelectMany(exception => exception.AffectedFiles.SelectMany(file => file.Statements), + (exception, s) => new DuplicateAuthoringStatementsException(s.Sid, exception.AffectedFiles)) + .ToList(); + + if (exceptions.Count > 0) + { + throw new AggregateException(exceptions); + } + + return Task.CompletedTask; + } + + static void UpdateStatus( + IReadOnlyList files, + IReadOnlyList toDelete, + IReadOnlyList toUpdate, + IReadOnlyList toCreate) + { + var allStatements = files.SelectMany(s => s.Statements).ToList(); + var updateIds = toUpdate.Select(s => s.Sid).ToHashSet(); + var createIds = toCreate.Select(s => s.Sid).ToHashSet(); + var deleteIds = toDelete.Select(s => s.Sid).ToHashSet(); + //Must update the local references, not the remote ones + SetStatus(allStatements.Where(s => updateIds.Contains(s.Sid)).ToList(), "Updated"); + SetStatus(allStatements.Where(s => createIds.Contains(s.Sid)).ToList(), "Created"); + SetStatus(allStatements.Where(s => deleteIds.Contains(s.Sid)).ToList(), "Deleted"); + } + + static void SetStatus(List statements, string action) + { + statements.ForEach(s => s.Status = new DeploymentStatus("Fetched", action, SeverityLevel.Success)); + } + + static void SetStatus(IReadOnlyList statements, string message, string detail, SeverityLevel level) + { + foreach (var statement in statements) + statement.Status = new DeploymentStatus(message, detail, level); + } + } +} diff --git a/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/IO/IFileSystem.cs b/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/IO/IFileSystem.cs new file mode 100644 index 0000000..2ea60ab --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/IO/IFileSystem.cs @@ -0,0 +1,19 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Unity.Services.Access.Authoring.Core.IO +{ + public interface IFileSystem + { + Task ReadAllText( + string path, + CancellationToken token = default(CancellationToken)); + + Task WriteAllText( + string path, + string contents, + CancellationToken token = default(CancellationToken)); + + Task Delete(string path, CancellationToken token = default(CancellationToken)); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/Json/IJsonConverter.cs b/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/Json/IJsonConverter.cs new file mode 100644 index 0000000..956f744 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/Json/IJsonConverter.cs @@ -0,0 +1,8 @@ +namespace Unity.Services.Access.Authoring.Core.Json +{ + public interface IJsonConverter + { + T DeserializeObject(string value, bool matchCamelCaseFieldName = false); + string SerializeObject(T obj); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/Model/AccessControlStatement.cs b/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/Model/AccessControlStatement.cs new file mode 100644 index 0000000..7ddb2c2 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/Model/AccessControlStatement.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Runtime.Serialization; +using Unity.Services.DeploymentApi.Editor; + +namespace Unity.Services.Access.Authoring.Core.Model +{ + [Serializable] + [DataContract(Name = "Statement")] + public class AccessControlStatement : IAcessControlStatement + { + public string Type => "Project Access Control Statement"; + + public AccessControlStatement() { } + [DataMember(Name = "Sid", IsRequired = true, EmitDefaultValue = true)] + public string Sid { get; set; } + + [DataMember(Name = "Action", IsRequired = true, EmitDefaultValue = true)] + public List Action { get; set; } + + [DataMember(Name = "Effect", IsRequired = true, EmitDefaultValue = true)] + public string Effect { get; set; } + + [DataMember(Name = "Principal", IsRequired = true, EmitDefaultValue = true)] + public string Principal { get; set; } + + [DataMember(Name = "Resource", IsRequired = true, EmitDefaultValue = true)] + public string Resource { get; set; } + + [DataMember(Name = "ExpiresAt", EmitDefaultValue = false)] + public DateTime ExpiresAt { get; set; } + + [DataMember(Name = "Version", EmitDefaultValue = false)] + public string Version { get; set; } + + float m_Progress; + DeploymentStatus m_Status; + + public string Name { get; set; } + public string Path { get; set; } + + public float Progress + { + get => m_Progress; + set => SetField(ref m_Progress, value); + } + + public DeploymentStatus Status + { + get => m_Status; + set => SetField(ref m_Status, value); + } + + public ObservableCollection States { get; } + + public override string ToString() + { + return $"'{Sid}' in '{Path}'"; + } + + public event PropertyChangedEventHandler PropertyChanged; + + protected void SetField( + ref T field, + T value, + Action onFieldChanged = null, + [CallerMemberName] string propertyName = null) + { + if (EqualityComparer.Default.Equals(field, value)) + return; + field = value; + OnPropertyChanged(propertyName!); + onFieldChanged?.Invoke(field); + } + + void OnPropertyChanged([CallerMemberName] string propertyName = "") + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + + public bool HasStatementChanged(IReadOnlyList referenceList) + { + var reference = referenceList.SingleOrDefault(r => r.Sid == Sid); + + return reference != null && (reference.Effect != Effect || reference.Principal != Principal || + reference.Resource != Resource || reference.Version != Version || + reference.ExpiresAt != ExpiresAt || + HasStatementActionChanged(reference.Action, Action)); + } + + static bool HasStatementActionChanged(List remoteAction, List localAction) + { + var orderedRemoteAction = remoteAction.OrderByDescending(s => s).ToList(); + var orderedLocalAction = localAction.OrderByDescending(s => s).ToList(); + + return orderedRemoteAction.Count != orderedLocalAction.Count + || !orderedLocalAction.SequenceEqual(orderedRemoteAction); + } + } +} diff --git a/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/Model/IAcessControlStatement.cs b/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/Model/IAcessControlStatement.cs new file mode 100644 index 0000000..b86e4d0 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/Model/IAcessControlStatement.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; +using Unity.Services.DeploymentApi.Editor; + +namespace Unity.Services.Access.Authoring.Core.Model +{ + public interface IAcessControlStatement : IDeploymentItem, ITypedItem + { + string Sid { get; set; } + List Action { get; set; } + string Effect { get; set; } + string Principal { get; set; } + string Resource { get; set; } + DateTime ExpiresAt { get; set; } + string Version { get; set; } + } +} diff --git a/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/Model/IProjectAccessFile.cs b/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/Model/IProjectAccessFile.cs new file mode 100644 index 0000000..49935d0 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/Model/IProjectAccessFile.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; +using Unity.Services.DeploymentApi.Editor; + +namespace Unity.Services.Access.Authoring.Core.Model +{ + public interface IProjectAccessFile : IDeploymentItem + { + List Statements { get; set; } + + new float Progress { get; set; } + } +} diff --git a/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/Model/IProjectAccessMerger.cs b/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/Model/IProjectAccessMerger.cs new file mode 100644 index 0000000..ca59de2 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/Model/IProjectAccessMerger.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; + +namespace Unity.Services.Access.Authoring.Core.Model +{ + public interface IProjectAccessMerger + { + List MergeStatementsToDeploy( + IReadOnlyList toCreate, + IReadOnlyList toUpdate, + IReadOnlyList toDelete, + IReadOnlyList remoteStatements); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/Model/IProjectAccessParser.cs b/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/Model/IProjectAccessParser.cs new file mode 100644 index 0000000..4678d1f --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/Model/IProjectAccessParser.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace Unity.Services.Access.Authoring.Core.Model +{ + public interface IProjectAccessParser + { + List ParseFile(ProjectAccessFileContent content, IProjectAccessFile file); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/Model/ProjectAccessFile.cs b/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/Model/ProjectAccessFile.cs new file mode 100644 index 0000000..07b040e --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/Model/ProjectAccessFile.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using Unity.Services.DeploymentApi.Editor; + +namespace Unity.Services.Access.Authoring.Core.Model +{ + [Serializable] + public class ProjectAccessFile : DeploymentItem, IProjectAccessFile + { + public ProjectAccessFile() + { + Type = "Project Access File"; + } + + public sealed override string Path + { + get => base.Path; + set + { + base.Path = value; + Name = System.IO.Path.GetFileName(value); + } + } + + public List Statements { get; set; } + + public override string ToString() + { + return $"'{Path}'"; + } + } +} diff --git a/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/Model/ProjectAccessFileContent.cs b/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/Model/ProjectAccessFileContent.cs new file mode 100644 index 0000000..9b66107 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/Model/ProjectAccessFileContent.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using System.Linq; + +namespace Unity.Services.Access.Authoring.Core.Model +{ + public class ProjectAccessFileContent + { + public readonly IReadOnlyList Statements; + + public ProjectAccessFileContent() + { + Statements = new List(); + } + public ProjectAccessFileContent(IReadOnlyList statements) + { + Statements = new List(statements); + } + + public List ToAuthoringStatements(IProjectAccessFile file, IProjectAccessParser parser) + { + return parser.ParseFile(this, file).ToList(); + } + } +} diff --git a/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/Model/ProjectAccessFileExtension.cs b/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/Model/ProjectAccessFileExtension.cs new file mode 100644 index 0000000..5df6390 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/Model/ProjectAccessFileExtension.cs @@ -0,0 +1,63 @@ +using System.Collections.Generic; +using System.Linq; + +namespace Unity.Services.Access.Authoring.Core.Model +{ + public static class ProjectAccessFileExtension + { + public static ProjectAccessFileContent ToFileContent(this IProjectAccessFile file) + { + return new ProjectAccessFileContent(file.Statements); + } + + public static void RemoveStatements(this IProjectAccessFile file, IReadOnlyList statementsToRemove) + { + foreach (var statementToRemove in statementsToRemove) + { + var index = file.Statements.FindIndex(statement => statement.Sid == statementToRemove.Sid); + + if (index >= 0) + { + statementToRemove.Path = file.Path; + statementToRemove.Name = statementToRemove.Sid; + } + } + + file.Statements.RemoveAll(statement => statementsToRemove.Any(statementToRemove => statement.Sid == statementToRemove.Sid)); + } + + public static void UpdateStatements(this IProjectAccessFile file, IReadOnlyList statementsToUpdate) + { + foreach (var statementToUpdate in statementsToUpdate) + { + var index = file.Statements.FindIndex(statement => statement.Sid == statementToUpdate.Sid); + + if (index >= 0) + { + file.Statements[index] = statementToUpdate; + statementToUpdate.Path = file.Path; + statementToUpdate.Name = statementToUpdate.Sid; + } + } + } + + public static void UpdateOrCreateStatements(this IProjectAccessFile file, IReadOnlyList statementsToCreateOrUpdate) + { + foreach (var statementToCreateOrUpdate in statementsToCreateOrUpdate) + { + var index = file.Statements.FindIndex(statement => statement.Sid == statementToCreateOrUpdate.Sid); + statementToCreateOrUpdate.Path = file.Path; + statementToCreateOrUpdate.Name = statementToCreateOrUpdate.Sid; + + if (index >= 0) + { + file.Statements[index] = statementToCreateOrUpdate; + } + else + { + file.Statements.Add(statementToCreateOrUpdate); + } + } + } + } +} diff --git a/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/Model/ProjectAccessMerger.cs b/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/Model/ProjectAccessMerger.cs new file mode 100644 index 0000000..da8bfc7 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/Model/ProjectAccessMerger.cs @@ -0,0 +1,41 @@ +using System.Collections.Generic; +using System.Linq; + +namespace Unity.Services.Access.Authoring.Core.Model +{ + public class ProjectAccessMerger: IProjectAccessMerger + { + public List MergeStatementsToDeploy( + IReadOnlyList toCreate, + IReadOnlyList toUpdate, + IReadOnlyList toDelete, + IReadOnlyList remoteStatements) + { + var localStatements = toCreate.Concat(toUpdate).ToList(); + var remoteStatementsExceptToDelete = remoteStatements.Except(toDelete).ToList(); + + return MergeStatements(localStatements, remoteStatementsExceptToDelete); + } + + static List MergeStatements( + IReadOnlyList localStatements, + IReadOnlyList remoteStatements) + { + var localStatementsList = localStatements.ToList(); + + var localStatementSids = localStatementsList.Select(statement => statement.Sid).ToList(); + var remoteStatementSids = remoteStatements.Select(statement => statement.Sid).ToList(); + + var conflicts = localStatementSids.Intersect(remoteStatementSids); + var cleanedUpStatementsFromRemote = + remoteStatements.Where(statement => !conflicts.Contains(statement.Sid)); + + var finalStatementList = new List(); + + finalStatementList.AddRange(localStatementsList); + finalStatementList.AddRange(cleanedUpStatementsFromRemote); + + return finalStatementList; + } + } +} diff --git a/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/Model/ProjectAccessParser.cs b/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/Model/ProjectAccessParser.cs new file mode 100644 index 0000000..a31b5ed --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/Model/ProjectAccessParser.cs @@ -0,0 +1,23 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; + +namespace Unity.Services.Access.Authoring.Core.Model +{ + public class ProjectAccessParser : IProjectAccessParser + { + public List ParseFile(ProjectAccessFileContent content, IProjectAccessFile file) + { + var authoringStatements = new List(); + foreach (var statement in content.Statements) + { + statement.Path = file.Path; + statement.Name = statement.Sid; + authoringStatements.Add(statement); + } + + return authoringStatements; + } + } +} diff --git a/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/Results/DeployResult.cs b/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/Results/DeployResult.cs new file mode 100644 index 0000000..98df3df --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/Results/DeployResult.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; +using Unity.Services.Access.Authoring.Core.Model; + +namespace Unity.Services.Access.Authoring.Core.Results +{ + public class DeployResult + { + public IReadOnlyList Created { get; set; } + public IReadOnlyList Updated { get; set; } + public IReadOnlyList Deleted { get; set; } + public IReadOnlyList Deployed { get; set; } + public IReadOnlyList Failed { get; set; } + } +} diff --git a/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/Results/FetchResult.cs b/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/Results/FetchResult.cs new file mode 100644 index 0000000..6511837 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/Results/FetchResult.cs @@ -0,0 +1,21 @@ +using System; +using System.Collections.Generic; +using Unity.Services.Access.Authoring.Core.Model; + +namespace Unity.Services.Access.Authoring.Core.Results +{ + public class FetchResult : Result + { + public IReadOnlyList Fetched { get; } + + public FetchResult( + IReadOnlyList created, + IReadOnlyList updated, + IReadOnlyList deleted, + IReadOnlyList fetched = null, + IReadOnlyList failed = null) : base(created, updated,deleted) + { + Fetched = fetched ?? Array.Empty(); + } + } +} diff --git a/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/Results/Result.cs b/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/Results/Result.cs new file mode 100644 index 0000000..c996075 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/Results/Result.cs @@ -0,0 +1,27 @@ +using System; +using System.Collections.Generic; +using Unity.Services.Access.Authoring.Core.Model; + +namespace Unity.Services.Access.Authoring.Core.Results +{ + public class Result + { + public IReadOnlyList Created { get; } + public IReadOnlyList Updated { get; } + public IReadOnlyList Deleted { get; } + + public IReadOnlyList Failed { get; } + + public Result( + IReadOnlyList created, + IReadOnlyList updated, + IReadOnlyList deleted, + IReadOnlyList failed = null) + { + Created = created; + Updated = updated; + Deleted = deleted; + Failed = failed ?? Array.Empty(); + } + } +} diff --git a/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/Service/IProjectAccessClient.cs b/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/Service/IProjectAccessClient.cs new file mode 100644 index 0000000..07f478f --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/Service/IProjectAccessClient.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Unity.Services.Access.Authoring.Core.Model; + +namespace Unity.Services.Access.Authoring.Core.Service +{ + public interface IProjectAccessClient + { + void Initialize(string environmentId, string projectId, CancellationToken cancellationToken); + + Task> GetAsync(); + Task UpsertAsync(IReadOnlyList authoringStatements); + Task DeleteAsync(IReadOnlyList authoringStatements); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/Unity.Services.Access.Authoring.Core.csproj b/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/Unity.Services.Access.Authoring.Core.csproj new file mode 100644 index 0000000..f3f0370 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/Unity.Services.Access.Authoring.Core.csproj @@ -0,0 +1,24 @@ + + + net5.0 + disable + 0.0.1 + disable + Library + true + true + + 9 + + + + <_Parameter1>Unity.Services.Cli.Access.UnitTest + + + <_Parameter1>DynamicProxyGenAssembly2 + + + + + + \ No newline at end of file diff --git a/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/Validations/IProjectAccessConfigValidator.cs b/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/Validations/IProjectAccessConfigValidator.cs new file mode 100644 index 0000000..0c50abe --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/Validations/IProjectAccessConfigValidator.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using Unity.Services.Access.Authoring.Core.ErrorHandling; +using Unity.Services.Access.Authoring.Core.Model; + +namespace Unity.Services.Access.Authoring.Core.Validations +{ + public interface IProjectAccessConfigValidator + { + List FilterNonDuplicatedAuthoringStatements( + IReadOnlyList files, + ICollection deploymentExceptions); + + bool Validate( + IProjectAccessFile file, + ICollection deploymentExceptions); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/Validations/ProjectAccessConfigValidator.cs b/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/Validations/ProjectAccessConfigValidator.cs new file mode 100644 index 0000000..1d5a69f --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Access.Authoring.Core/Validations/ProjectAccessConfigValidator.cs @@ -0,0 +1,133 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using Unity.Services.Access.Authoring.Core.ErrorHandling; +using Unity.Services.Access.Authoring.Core.Model; +using Unity.Services.DeploymentApi.Editor; + +namespace Unity.Services.Access.Authoring.Core.Validations +{ + public class ProjectAccessConfigValidator : IProjectAccessConfigValidator + { + public List FilterNonDuplicatedAuthoringStatements( + IReadOnlyList files, + ICollection deploymentExceptions) + { + var nonDuplicatedStatements = new List(); + + foreach (var f in files) + { + ProjectAccessFile file = (ProjectAccessFile)f; + + foreach (var statement in file.Statements) + { + + var containingFiles = files + .Where(f => f.Statements.Exists(fs => fs.Sid == statement.Sid)) + .ToList(); + + if (containingFiles.Count > 1) + { + deploymentExceptions.Add(new DuplicateAuthoringStatementsException(statement.Sid, containingFiles)); + file.Status = new DeploymentStatus("Validation Error", $"Multiple resources with the same identifier '{statement.Sid}' were found. ", SeverityLevel.Error); + continue; + } + + var duplicatedStatementsInASameFileCount = file.Statements + .GroupBy(s => s.Sid).Count(t => t.Count() > 1); + + if (duplicatedStatementsInASameFileCount > 0) + { + containingFiles.Add(file); + deploymentExceptions.Add(new DuplicateAuthoringStatementsException(statement.Sid, containingFiles)); + file.Status = new DeploymentStatus("Validation Error", $"Multiple resources with the same identifier '{statement.Sid}' were found. ", SeverityLevel.Error); + continue; + } + + nonDuplicatedStatements.Add(statement); + } + + } + + return nonDuplicatedStatements; + } + + public bool Validate( + IProjectAccessFile file, + ICollection deploymentExceptions) + { + bool validated = true; + + foreach (var authoringStatement in file.Statements) + { + var isSidValidated = ValidateSid(authoringStatement.Sid, (ProjectAccessFile)file, deploymentExceptions); + var isResourceValidated = ValidateResource(authoringStatement.Resource, (ProjectAccessFile)file, deploymentExceptions); + var isActionValidated = ValidateAction(authoringStatement.Action, (ProjectAccessFile)file, deploymentExceptions); + var isPrincipalValidated = ValidatePrincipal(authoringStatement.Principal, (ProjectAccessFile)file, deploymentExceptions); + var isEffectValidated = ValidateEffect(authoringStatement.Effect, (ProjectAccessFile)file, deploymentExceptions); + + if (!isSidValidated || !isResourceValidated || !isActionValidated || !isPrincipalValidated || !isEffectValidated) + { + validated = false; + } + } + + return validated; + } + + static bool ValidateSid(string sid, ProjectAccessFile projectAccessFile, ICollection deploymentExceptions) + { + Regex regex = new Regex("^[A-Za-z0-9][A-Za-z0-9_-]{5,59}$", RegexOptions.CultureInvariant, matchTimeout: TimeSpan.FromSeconds(2)); + if (regex.Match(sid).Success) return true; + + deploymentExceptions.Add(new InvalidDataException(projectAccessFile, "Invalid value for Sid, must match a pattern of " + regex)); + projectAccessFile.Status = new DeploymentStatus("Validation Error", "Invalid value for Sid, must match a pattern of " + regex, SeverityLevel.Error); + return false; + + } + + static bool ValidateResource(string resource, ProjectAccessFile projectAccessFile, ICollection deploymentExceptions) + { + Regex regex = new Regex("^urn:ugs:(([a-z-]*:){1}[*/]*[/a-z0-9-*]*|\\*{1})", RegexOptions.CultureInvariant, matchTimeout: TimeSpan.FromSeconds(2)); + if (regex.Match(resource).Success) return true; + + deploymentExceptions.Add(new InvalidDataException(projectAccessFile, "Invalid value for Resource, must match a pattern of " + regex)); + projectAccessFile.Status = new DeploymentStatus("Validation Error", "Invalid value for Resource, must match a pattern of " + regex, SeverityLevel.Error); + return false; + } + + static bool ValidateAction(List action, ProjectAccessFile projectAccessFile, ICollection deploymentExceptions) + { + var validActions = new List{ "*", "Read", "Write", "Vivox:JoinMuted", "Vivox:JoinAllMuted" }; + var invalidActions = action.Where(v => !validActions.Contains(v)); + if (!invalidActions.Any()) return true; + + deploymentExceptions.Add(new InvalidDataException(projectAccessFile, "Invalid Value for Action, must be '*', 'Read', 'Write', 'Vivox:JoinMuted' or 'Vivox:JoinAllMuted'")); + projectAccessFile.Status = new DeploymentStatus("Validation Error", "Invalid Value for Action, must be '*', 'Read', 'Write', 'Vivox:JoinMuted' or 'Vivox:JoinAllMuted'", SeverityLevel.Error); + return false; + } + + static bool ValidatePrincipal(string principal, ProjectAccessFile projectAccessFile, ICollection deploymentExceptions) + { + var validPrincipals = new List{ "Player" }; + if (validPrincipals.Contains(principal)) return true; + + deploymentExceptions.Add(new InvalidDataException(projectAccessFile, "Invalid Value for Principal, must be 'Player'")); + projectAccessFile.Status = new DeploymentStatus("Validation Error", "Invalid Value for Principal, must be 'Player'", SeverityLevel.Error); + return false; + } + + static bool ValidateEffect(string effect, ProjectAccessFile projectAccessFile, ICollection deploymentExceptions) + { + var validEffects = new List{ "Allow", "Deny" }; + if (validEffects.Contains(effect)) return true; + + deploymentExceptions.Add(new InvalidDataException(projectAccessFile, "Invalid Value for Effect, must be 'Allow' or 'Deny")); + projectAccessFile.Status = new DeploymentStatus("Validation Error", "Invalid Value for Effect, must be 'Allow' or 'Deny", SeverityLevel.Error); + return false; + } + + + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Access.UnitTest/AccessModuleTests.cs b/Unity.Services.Cli/Unity.Services.Cli.Access.UnitTest/AccessModuleTests.cs index 77da005..401ef28 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.Access.UnitTest/AccessModuleTests.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.Access.UnitTest/AccessModuleTests.cs @@ -15,25 +15,25 @@ namespace Unity.Services.Cli.Access.UnitTest; [TestFixture] public class AccessModuleTests { - readonly AccessModule k_AccessModule = new(); + readonly AccessModule m_AccessModule = new(); [Test] public void BuildCommands_CreateCommands() { var commandLineBuilder = new CommandLineBuilder(); - commandLineBuilder.AddModule(k_AccessModule); - TestsHelper.AssertContainsCommand(commandLineBuilder.Command, k_AccessModule.ModuleRootCommand!.Name, out var resultCommand); + commandLineBuilder.AddModule(m_AccessModule); + TestsHelper.AssertContainsCommand(commandLineBuilder.Command, m_AccessModule.ModuleRootCommand!.Name, out var resultCommand); Assert.Multiple(() => { - Assert.That(resultCommand, Is.EqualTo(k_AccessModule.ModuleRootCommand)); - Assert.That(k_AccessModule.GetPlayerPolicyCommand!.Handler, Is.Not.Null); - Assert.That(k_AccessModule.GetProjectPolicyCommand!.Handler, Is.Not.Null); - Assert.That(k_AccessModule.GetAllPlayerPoliciesCommand!.Handler, Is.Not.Null); - Assert.That(k_AccessModule.UpsertProjectPolicyCommand!.Handler, Is.Not.Null); - Assert.That(k_AccessModule.UpsertPlayerPolicyCommand!.Handler, Is.Not.Null); - Assert.That(k_AccessModule.DeleteProjectPolicyStatementsCommand!.Handler, Is.Not.Null); - Assert.That(k_AccessModule.ModuleRootCommand!.Aliases, Does.Contain("ac")); - Assert.That(k_AccessModule.DeletePlayerPolicyStatementsCommand!.Handler, Is.Not.Null); + Assert.That(resultCommand, Is.EqualTo(m_AccessModule.ModuleRootCommand)); + Assert.That(m_AccessModule.GetPlayerPolicyCommand!.Handler, Is.Not.Null); + Assert.That(m_AccessModule.GetProjectPolicyCommand!.Handler, Is.Not.Null); + Assert.That(m_AccessModule.GetAllPlayerPoliciesCommand!.Handler, Is.Not.Null); + Assert.That(m_AccessModule.UpsertProjectPolicyCommand!.Handler, Is.Not.Null); + Assert.That(m_AccessModule.UpsertPlayerPolicyCommand!.Handler, Is.Not.Null); + Assert.That(m_AccessModule.DeleteProjectPolicyStatementsCommand!.Handler, Is.Not.Null); + Assert.That(m_AccessModule.ModuleRootCommand!.Aliases, Does.Contain("ac")); + Assert.That(m_AccessModule.DeletePlayerPolicyStatementsCommand!.Handler, Is.Not.Null); }); } @@ -42,8 +42,8 @@ public void GetProjectPolicyCommand_ContainsRequiredInputs() { Assert.Multiple(() => { - Assert.That(k_AccessModule.GetProjectPolicyCommand!.Options, Does.Contain(CommonInput.CloudProjectIdOption)); - Assert.That(k_AccessModule.GetProjectPolicyCommand!.Options, Does.Contain(CommonInput.EnvironmentNameOption)); + Assert.That(m_AccessModule.GetProjectPolicyCommand!.Options, Does.Contain(CommonInput.CloudProjectIdOption)); + Assert.That(m_AccessModule.GetProjectPolicyCommand!.Options, Does.Contain(CommonInput.EnvironmentNameOption)); }); } @@ -52,9 +52,9 @@ public void GetPlayerPolicyCommand_ContainsRequiredInputs() { Assert.Multiple(() => { - Assert.That(k_AccessModule.GetPlayerPolicyCommand!.Options, Does.Contain(CommonInput.CloudProjectIdOption)); - Assert.That(k_AccessModule.GetPlayerPolicyCommand!.Options, Does.Contain(CommonInput.EnvironmentNameOption)); - Assert.That(k_AccessModule.GetPlayerPolicyCommand.Arguments, Does.Contain(AccessInput.PlayerIdArgument)); + Assert.That(m_AccessModule.GetPlayerPolicyCommand!.Options, Does.Contain(CommonInput.CloudProjectIdOption)); + Assert.That(m_AccessModule.GetPlayerPolicyCommand!.Options, Does.Contain(CommonInput.EnvironmentNameOption)); + Assert.That(m_AccessModule.GetPlayerPolicyCommand.Arguments, Does.Contain(AccessInput.PlayerIdArgument)); }); } @@ -63,8 +63,8 @@ public void GetAllPlayerPoliciesCommand_ContainsRequiredInputs() { Assert.Multiple(() => { - Assert.That(k_AccessModule.GetAllPlayerPoliciesCommand!.Options, Does.Contain(CommonInput.CloudProjectIdOption)); - Assert.That(k_AccessModule.GetAllPlayerPoliciesCommand!.Options, Does.Contain(CommonInput.EnvironmentNameOption)); + Assert.That(m_AccessModule.GetAllPlayerPoliciesCommand!.Options, Does.Contain(CommonInput.CloudProjectIdOption)); + Assert.That(m_AccessModule.GetAllPlayerPoliciesCommand!.Options, Does.Contain(CommonInput.EnvironmentNameOption)); }); } @@ -73,9 +73,9 @@ public void UpsertProjectPolicyCommand_ContainsRequiredInputs() { Assert.Multiple(() => { - Assert.That(k_AccessModule.UpsertProjectPolicyCommand!.Options, Does.Contain(CommonInput.CloudProjectIdOption)); - Assert.That(k_AccessModule.UpsertProjectPolicyCommand!.Options, Does.Contain(CommonInput.EnvironmentNameOption)); - Assert.That(k_AccessModule.UpsertProjectPolicyCommand!.Arguments, Does.Contain(AccessInput.FilePathArgument)); + Assert.That(m_AccessModule.UpsertProjectPolicyCommand!.Options, Does.Contain(CommonInput.CloudProjectIdOption)); + Assert.That(m_AccessModule.UpsertProjectPolicyCommand!.Options, Does.Contain(CommonInput.EnvironmentNameOption)); + Assert.That(m_AccessModule.UpsertProjectPolicyCommand!.Arguments, Does.Contain(AccessInput.FilePathArgument)); }); } @@ -84,10 +84,10 @@ public void UpsertPlayerPolicyCommand_ContainsRequiredInputs() { Assert.Multiple(() => { - Assert.That(k_AccessModule.UpsertPlayerPolicyCommand!.Options, Does.Contain(CommonInput.CloudProjectIdOption)); - Assert.That(k_AccessModule.UpsertPlayerPolicyCommand!.Options, Does.Contain(CommonInput.EnvironmentNameOption)); - Assert.That(k_AccessModule.UpsertPlayerPolicyCommand!.Arguments, Does.Contain(AccessInput.PlayerIdArgument)); - Assert.That(k_AccessModule.UpsertPlayerPolicyCommand!.Arguments, Does.Contain(AccessInput.FilePathArgument)); + Assert.That(m_AccessModule.UpsertPlayerPolicyCommand!.Options, Does.Contain(CommonInput.CloudProjectIdOption)); + Assert.That(m_AccessModule.UpsertPlayerPolicyCommand!.Options, Does.Contain(CommonInput.EnvironmentNameOption)); + Assert.That(m_AccessModule.UpsertPlayerPolicyCommand!.Arguments, Does.Contain(AccessInput.PlayerIdArgument)); + Assert.That(m_AccessModule.UpsertPlayerPolicyCommand!.Arguments, Does.Contain(AccessInput.FilePathArgument)); }); } @@ -96,9 +96,9 @@ public void DeleteProjectPolicyStatements_ContainsRequiredInputs() { Assert.Multiple(() => { - Assert.That(k_AccessModule.DeleteProjectPolicyStatementsCommand!.Options, Does.Contain(CommonInput.CloudProjectIdOption)); - Assert.That(k_AccessModule.DeleteProjectPolicyStatementsCommand!.Options, Does.Contain(CommonInput.EnvironmentNameOption)); - Assert.That(k_AccessModule.DeleteProjectPolicyStatementsCommand!.Arguments, Does.Contain(AccessInput.FilePathArgument)); + Assert.That(m_AccessModule.DeleteProjectPolicyStatementsCommand!.Options, Does.Contain(CommonInput.CloudProjectIdOption)); + Assert.That(m_AccessModule.DeleteProjectPolicyStatementsCommand!.Options, Does.Contain(CommonInput.EnvironmentNameOption)); + Assert.That(m_AccessModule.DeleteProjectPolicyStatementsCommand!.Arguments, Does.Contain(AccessInput.FilePathArgument)); }); } @@ -107,10 +107,10 @@ public void DeletePlayerPolicyStatements_ContainsRequiredInputs() { Assert.Multiple(() => { - Assert.That(k_AccessModule.DeletePlayerPolicyStatementsCommand!.Options, Does.Contain(CommonInput.CloudProjectIdOption)); - Assert.That(k_AccessModule.DeletePlayerPolicyStatementsCommand!.Options, Does.Contain(CommonInput.EnvironmentNameOption)); - Assert.That(k_AccessModule.DeletePlayerPolicyStatementsCommand!.Arguments, Does.Contain(AccessInput.PlayerIdArgument)); - Assert.That(k_AccessModule.DeletePlayerPolicyStatementsCommand!.Arguments, Does.Contain(AccessInput.FilePathArgument)); + Assert.That(m_AccessModule.DeletePlayerPolicyStatementsCommand!.Options, Does.Contain(CommonInput.CloudProjectIdOption)); + Assert.That(m_AccessModule.DeletePlayerPolicyStatementsCommand!.Options, Does.Contain(CommonInput.EnvironmentNameOption)); + Assert.That(m_AccessModule.DeletePlayerPolicyStatementsCommand!.Arguments, Does.Contain(AccessInput.PlayerIdArgument)); + Assert.That(m_AccessModule.DeletePlayerPolicyStatementsCommand!.Arguments, Does.Contain(AccessInput.FilePathArgument)); }); } diff --git a/Unity.Services.Cli/Unity.Services.Cli.Access.UnitTest/Deploy/AccessConfigLoaderTests.cs b/Unity.Services.Cli/Unity.Services.Cli.Access.UnitTest/Deploy/AccessConfigLoaderTests.cs new file mode 100644 index 0000000..0feeafb --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Access.UnitTest/Deploy/AccessConfigLoaderTests.cs @@ -0,0 +1,84 @@ +using System.IO.Abstractions; +using Moq; +using NUnit.Framework; +using Unity.Services.Cli.Access.Deploy; +using Unity.Services.Access.Authoring.Core.Json; +using IFileSystem = Unity.Services.Access.Authoring.Core.IO.IFileSystem; + +namespace Unity.Services.Cli.Access.UnitTest.Deploy; + +[TestFixture] +public class AccessConfigLoaderTests +{ + AccessConfigLoader? m_AccessConfigLoader; + readonly Mock m_FileSystem = new(); + readonly IJsonConverter m_JsonConverter = new JsonConverter(); + readonly Mock m_Path = new(); + + [Test] + public async Task ConfigLoader_Deserializes() + { + m_AccessConfigLoader = new AccessConfigLoader( + m_FileSystem.Object, + m_Path.Object, + m_JsonConverter); + var content = @"{ + 'Statements': [ + { + 'Sid': 'allow-access-to-economy', + 'Action': [ + 'Read' + ], + 'Effect': 'Allow', + 'Principal': 'Player', + 'Resource': 'urn:ugs:economy:*', + 'ExpiresAt': '2024-04-29T18:30:51.243Z', + 'Version': '10.0' + } + ] +}"; + m_FileSystem.Setup(f => f.ReadAllText(It.IsAny(), It.IsAny())) + .ReturnsAsync(content); + + var configs = await m_AccessConfigLoader + .LoadFilesAsync( + new[] + { + "path" + }, + CancellationToken.None); + + var config = configs.Loaded[0]; + Assert.Multiple(() => + { + Assert.That(config.Statements[0].Sid, Is.EqualTo("allow-access-to-economy")); + Assert.That(config.Statements[0].Version, Is.EqualTo("10.0")); + }); + } + + [Test] + public async Task ConfigLoader_DeserializesShouldFail() + { + m_AccessConfigLoader = new AccessConfigLoader( + m_FileSystem.Object, + m_Path.Object, + m_JsonConverter); + var content = @""; + + m_FileSystem.Setup(f => f.ReadAllText(It.IsAny(), It.IsAny())) + .ReturnsAsync(content); + + var configs = await m_AccessConfigLoader + .LoadFilesAsync( + new[] + { + "path" + }, + CancellationToken.None); + Assert.Multiple(() => + { + Assert.That(configs.Failed, Has.Count.EqualTo(1)); + Assert.That(configs.Loaded, Is.Empty); + }); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Access.UnitTest/Deploy/AuthoringResultTests.cs b/Unity.Services.Cli/Unity.Services.Cli.Access.UnitTest/Deploy/AuthoringResultTests.cs new file mode 100644 index 0000000..c33a003 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Access.UnitTest/Deploy/AuthoringResultTests.cs @@ -0,0 +1,60 @@ + +using NUnit.Framework; +using Unity.Services.Access.Authoring.Core.Model; +using Unity.Services.Cli.Access.Deploy; +using Unity.Services.DeploymentApi.Editor; + +namespace Unity.Services.Cli.Access.UnitTest.Deploy; + +[TestFixture] +public class AuthoringResultTestsx +{ + [Test] + public void Test_Orders_ofComplexitx() + { + var statements1 = new List() + { + new() { Sid = "one" }, + new() { Sid = "two" }, + new() { Sid = "three" } + }; + + var statements2 = new List() + { + new() { Sid = "alpha" }, + new() { Sid = "beta" }, + new() { Sid = "gamma" } + }; + + var file1 = new ProjectAccessFile() + { + Name = "test-file.ac", + Statements = statements1 + }; + + var file2 = new ProjectAccessFile() + { + Name = "test-file.ac", + Statements = statements2 + }; + var ar = new AccessDeploymentResult( + statements1.Concat(statements2).ToList(), + Array.Empty(), + Array.Empty(), + new []{file1, file2}, + Array.Empty() + ); + + var expectedTable = new List(); + expectedTable.Add(file1); + expectedTable.AddRange(file1.Statements); + expectedTable.Add(file2); + expectedTable.AddRange(file2.Statements); + + var table = ar.ToTable(); + foreach (var (actual,expected) in table.Result.Zip(expectedTable)) + { + Assert.That(actual.Name, Is.EqualTo(expected.Name));; + } + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Access.UnitTest/Deploy/JsonConverterTests.cs b/Unity.Services.Cli/Unity.Services.Cli.Access.UnitTest/Deploy/JsonConverterTests.cs new file mode 100644 index 0000000..db06795 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Access.UnitTest/Deploy/JsonConverterTests.cs @@ -0,0 +1,75 @@ +using NUnit.Framework; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Unity.Services.Access.Authoring.Core.Model; +using Unity.Services.Cli.Access.UnitTest.Utils; +using JsonConverter = Unity.Services.Cli.Access.Deploy.JsonConverter; + +namespace Unity.Services.Cli.Access.UnitTest.Deploy; + +[TestFixture, Ignore("Flaky, I cant figure out why...")] +public class JsonConverterTests +{ + + readonly JsonConverter m_Converter = new(); + JsonSerializerSettings? m_Settings; + + [SetUp] + public void Setup() + { + m_Settings = new JsonSerializerSettings() + { + Formatting = Formatting.Indented, + Converters = { new StringEnumConverter() }, + NullValueHandling = NullValueHandling.Ignore, + DefaultValueHandling = DefaultValueHandling.Ignore + }; + } + + [Test] + public void SerializeObjectSerializesSuccessfully() + { + var testData = GetTestData(); + var serializedData = m_Converter.SerializeObject(testData); + var json = Newtonsoft.Json.JsonConvert.SerializeObject(testData, m_Settings); + Assert.That(serializedData, Is.EqualTo(json)); + } + + [Test] + public void DeserializeObjectWithDefaultResolverDeserializesSuccessfully() + { + var testData = GetTestData(); + var json = JsonConvert.SerializeObject(testData, m_Settings); + var deserializedData = m_Converter.DeserializeObject(json); + Assert.Multiple(() => + { + Assert.That(deserializedData.Statements[0].Sid, Is.EqualTo(testData.Statements[0].Sid)); + Assert.That(deserializedData.Statements[1].Sid, Is.EqualTo(testData.Statements[1].Sid)); + }); + } + + [Test] + public void DeserializeObjectWithCamelCaseResolverDeserializesSuccessfully() + { + var testData = GetTestData(); + var json = Newtonsoft.Json.JsonConvert.SerializeObject(testData, m_Settings); + var lowerCaseJson = json.ToLower(); + var deserializedData = m_Converter.DeserializeObject(lowerCaseJson, true); + Assert.Multiple(() => + { + Assert.That(deserializedData.Statements[0].Sid, Is.EqualTo(testData.Statements[0].Sid)); + Assert.That(deserializedData.Statements[1].Sid, Is.EqualTo(testData.Statements[1].Sid)); + }); + } + + static ProjectAccessFileContent GetTestData() + { + IReadOnlyList statements = new[] + { + TestMocks.GetAuthoringStatement("test-sid-1"), + TestMocks.GetAuthoringStatement("test-sid-2") + }; + var testData = new ProjectAccessFileContent(statements); + return testData; + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Access.UnitTest/Deploy/ProjectAccessClientTests.cs b/Unity.Services.Cli/Unity.Services.Cli.Access.UnitTest/Deploy/ProjectAccessClientTests.cs new file mode 100644 index 0000000..e174d98 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Access.UnitTest/Deploy/ProjectAccessClientTests.cs @@ -0,0 +1,102 @@ +using Moq; +using Newtonsoft.Json; +using NUnit.Framework; +using Unity.Services.Access.Authoring.Core.Model; +using Unity.Services.Cli.Access.Deploy; +using Unity.Services.Cli.Access.Service; +using Unity.Services.Cli.Access.UnitTest.Utils; +using Unity.Services.Gateway.AccessApiV1.Generated.Model; + +namespace Unity.Services.Cli.Access.UnitTest.Deploy; + +[TestFixture] +public class ProjectAccessClientTests +{ + const string k_TestProjectId = "a912b1fd-541d-42e1-89f2-85436f27aabd"; + const string k_TestEnvironmentId = "6d06a033-8a15-4919-8e8d-a731e08be87c"; + + readonly Mock m_MockAccessService = new(); + ProjectAccessClient? m_ProjectAccessClient; + + [SetUp] + public void SetUp() + { + m_MockAccessService.Reset(); + m_ProjectAccessClient = new ProjectAccessClient( + m_MockAccessService.Object, + k_TestProjectId, + k_TestEnvironmentId, + CancellationToken.None); + } + + [Test] + public void InitializeChangeProperties() + { + m_ProjectAccessClient = new ProjectAccessClient(m_MockAccessService.Object); + Assert.Multiple(() => + { + Assert.That(m_ProjectAccessClient.ProjectId, Is.EqualTo(string.Empty)); + Assert.That(m_ProjectAccessClient.EnvironmentId, Is.EqualTo(string.Empty)); + Assert.That(m_ProjectAccessClient.CancellationToken, Is.EqualTo(CancellationToken.None)); + }); + CancellationToken cancellationToken = new(true); + m_ProjectAccessClient!.Initialize( k_TestEnvironmentId, k_TestProjectId, cancellationToken); + Assert.Multiple(() => + { + Assert.That(m_ProjectAccessClient.ProjectId, Is.SameAs(k_TestProjectId)); + Assert.That(m_ProjectAccessClient.EnvironmentId, Is.SameAs(k_TestEnvironmentId)); + Assert.That(m_ProjectAccessClient.CancellationToken, Is.EqualTo(cancellationToken)); + }); + } + + [Test] + public async Task GetAsyncForPolicyWithNoStatements() + { + var policy = new Policy(); + m_MockAccessService.Setup(r => r.GetPolicyAsync(k_TestProjectId, k_TestEnvironmentId, CancellationToken.None)).ReturnsAsync(policy); + + var authoringStatements = new List() + { + TestMocks.GetAuthoringStatement() + }; + var result = await m_ProjectAccessClient!.GetAsync(); + + m_MockAccessService.Verify(ac => ac.GetPolicyAsync(k_TestProjectId, k_TestEnvironmentId, CancellationToken.None), Times.Once); + Assert.That(result, Has.Exactly(0).Items); + } + + [Test] + public async Task GetAsyncForPolicyWithStatements() + { + var policy = TestMocks.GetPolicy(new List(){TestMocks.GetStatement()}); + m_MockAccessService.Setup(r => r.GetPolicyAsync(k_TestProjectId, k_TestEnvironmentId, CancellationToken.None)).ReturnsAsync(policy); + + var authoringStatements = new List() + { + TestMocks.GetAuthoringStatement() + }; + var expectedResult = JsonConvert.SerializeObject(authoringStatements); + var result = JsonConvert.SerializeObject(await m_ProjectAccessClient!.GetAsync()); + + m_MockAccessService.Verify(ac => ac.GetPolicyAsync(k_TestProjectId, k_TestEnvironmentId, CancellationToken.None), Times.Once); + StringAssert.AreEqualIgnoringCase(expectedResult, result); + } + + [Test] + public async Task UpsertAsyncSuccessfully() + { + var authoringStatements = new List(){TestMocks.GetAuthoringStatement("sid-1"), TestMocks.GetAuthoringStatement("sid-2")}; + var policy = TestMocks.GetPolicy(new List(){TestMocks.GetStatement("sid-1"), TestMocks.GetStatement("sid-2")}); + await m_ProjectAccessClient!.UpsertAsync(authoringStatements); + m_MockAccessService.Verify(ac => ac.UpsertProjectAccessCaCAsync(k_TestProjectId, k_TestEnvironmentId, policy, CancellationToken.None), Times.Once); + } + + [Test] + public async Task DeleteAsyncSuccessfully() + { + var authoringStatements = new List(){TestMocks.GetAuthoringStatement("sid-1"), TestMocks.GetAuthoringStatement("sid-2")}; + var deleteOptions = TestMocks.GetDeleteOptions(new List(){"sid-1", "sid-2"}); + await m_ProjectAccessClient!.DeleteAsync(authoringStatements); + m_MockAccessService.Verify(ac => ac.DeleteProjectAccessCaCAsync(k_TestProjectId, k_TestEnvironmentId, deleteOptions, CancellationToken.None), Times.Once); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Access.UnitTest/Deploy/ProjectAccessDeploymentHandlerTests.cs b/Unity.Services.Cli/Unity.Services.Cli.Access.UnitTest/Deploy/ProjectAccessDeploymentHandlerTests.cs new file mode 100644 index 0000000..e2b6e8f --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Access.UnitTest/Deploy/ProjectAccessDeploymentHandlerTests.cs @@ -0,0 +1,272 @@ +using Moq; +using NUnit.Framework; +using Unity.Services.Access.Authoring.Core.Deploy; +using Unity.Services.Access.Authoring.Core.Model; +using Unity.Services.Access.Authoring.Core.Service; +using Unity.Services.Access.Authoring.Core.Validations; +using Unity.Services.Cli.Access.UnitTest.Utils; +using InvalidDataException = Unity.Services.Access.Authoring.Core.ErrorHandling.InvalidDataException; + +namespace Unity.Services.Cli.Access.UnitTest.Deploy; + +[TestFixture] +class ProjectAccessDeploymentHandlerTests +{ + [Test] + public async Task DeployAsync_CorrectResult() + { + var localProjectAccessFiles = GetLocalProjectAccessFiles(); + var remoteStatements = GetRemoteAuthoringStatements(); + + Mock mockProjectAccessClient = new(); + IProjectAccessConfigValidator projectConfigValidator = new ProjectAccessConfigValidator(); + IProjectAccessMerger projectAccessMerger = new ProjectAccessMerger(); + + var handler = new ProjectAccessDeploymentHandler( + mockProjectAccessClient.Object, + projectConfigValidator, + projectAccessMerger + ); + + mockProjectAccessClient + .Setup(client => client.GetAsync()) + .ReturnsAsync(remoteStatements.ToList()); + + mockProjectAccessClient + .Setup(client => client.UpsertAsync(remoteStatements)); + + var result = await handler.DeployAsync(localProjectAccessFiles, dryRun: false, reconcile: false); + + Assert.Multiple(() => + { + Assert.That(result.Created, Has.Count.EqualTo(1)); + Assert.That(result.Created[0].Sid, Is.EqualTo("deny-access-to-lobby")); + Assert.That(result.Updated, Has.Count.EqualTo(1)); + Assert.That(result.Updated[0].Sid, Is.EqualTo("allow-access-to-cloud-save")); + Assert.That(result.Deleted, Is.Empty); + Assert.That(result.Deployed, Has.Count.EqualTo(1)); + Assert.That(result.Failed, Is.Empty); + }); + } + + [Test] + public async Task DeployAsync_UpsertCallMade() + { + var localProjectAccessFiles = GetLocalProjectAccessFiles(); + + Mock mockProjectAccessClient = new(); + IProjectAccessConfigValidator projectConfigValidator = new ProjectAccessConfigValidator(); + IProjectAccessMerger projectAccessMerger = new ProjectAccessMerger(); + + var handler = new ProjectAccessDeploymentHandler( + mockProjectAccessClient.Object, + projectConfigValidator, + projectAccessMerger + ); + + await handler.DeployAsync(localProjectAccessFiles, dryRun: false, reconcile: false); + + mockProjectAccessClient + .Verify( + client => client.UpsertAsync(localProjectAccessFiles[0].Statements), + Times.Once); + } + + [Test] + public async Task DeployAsync_NoReconcileNoDeleteCalls() + { + var localEmptyProjectAccessFiles = GetLocalEmptyProjectAccessFiles(); + var remoteStatements = GetRemoteAuthoringStatements(); + + Mock mockProjectAccessClient = new(); + IProjectAccessConfigValidator projectConfigValidator = new ProjectAccessConfigValidator(); + IProjectAccessMerger projectAccessMerger = new ProjectAccessMerger(); + + var handler = new ProjectAccessDeploymentHandler( + mockProjectAccessClient.Object, + projectConfigValidator, + projectAccessMerger + ); + + mockProjectAccessClient + .Setup(client => client.GetAsync()) + .ReturnsAsync(remoteStatements.ToList()); + + await handler.DeployAsync(localEmptyProjectAccessFiles, dryRun: false, reconcile: false); + + mockProjectAccessClient + .Verify( + client => client.DeleteAsync(localEmptyProjectAccessFiles[0].Statements), + Times.Never); + } + + [Test] + public async Task DeployAsync_ReconcileDeleteCalls() + { + var localEmptyProjectAccessFiles = GetLocalEmptyProjectAccessFiles(); + var remoteStatements = GetRemoteAuthoringStatements(); + + Mock mockProjectAccessClient = new(); + IProjectAccessConfigValidator projectConfigValidator = new ProjectAccessConfigValidator(); + IProjectAccessMerger projectAccessMerger = new ProjectAccessMerger(); + + var handler = new ProjectAccessDeploymentHandler( + mockProjectAccessClient.Object, + projectConfigValidator, + projectAccessMerger + ); + + mockProjectAccessClient + .Setup(client => client.GetAsync()) + .ReturnsAsync(remoteStatements.ToList()); + + await handler.DeployAsync(localEmptyProjectAccessFiles, dryRun: false, reconcile: true); + + mockProjectAccessClient + .Verify( + client => client.DeleteAsync(remoteStatements), + Times.Once); + } + + [Test] + public async Task DeployAsync_DryRunNoCalls() + { + var localProjectAccessFiles = GetLocalProjectAccessFiles(); + var remoteStatements = GetRemoteAuthoringStatements(); + + Mock mockProjectAccessClient = new(); + IProjectAccessConfigValidator projectConfigValidator = new ProjectAccessConfigValidator(); + IProjectAccessMerger projectAccessMerger = new ProjectAccessMerger(); + + var handler = new ProjectAccessDeploymentHandler( + mockProjectAccessClient.Object, + projectConfigValidator, + projectAccessMerger + ); + + mockProjectAccessClient + .Setup(client => client.GetAsync()) + .ReturnsAsync(remoteStatements.ToList()); + + await handler.DeployAsync(localProjectAccessFiles, dryRun: true, reconcile: false); + + mockProjectAccessClient + .Verify( + client => client.UpsertAsync(localProjectAccessFiles[0].Statements), + Times.Never); + + mockProjectAccessClient + .Verify( + client => client.DeleteAsync(remoteStatements), + Times.Never); + } + + [Test] + public async Task DeployAsync_DryRunCorrectResult() + { + var localProjectAccessFiles = GetLocalProjectAccessFiles(); + var remoteStatements = GetRemoteAuthoringStatements(); + + + Mock mockProjectAccessClient = new(); + IProjectAccessConfigValidator projectConfigValidator = new ProjectAccessConfigValidator(); + IProjectAccessMerger projectAccessMerger = new ProjectAccessMerger(); + + var handler = new ProjectAccessDeploymentHandler( + mockProjectAccessClient.Object, + projectConfigValidator, + projectAccessMerger + ); + + mockProjectAccessClient + .Setup(client => client.GetAsync()) + .ReturnsAsync(remoteStatements.ToList()); + + mockProjectAccessClient + .Setup(client => client.UpsertAsync(remoteStatements)); + + var result = await handler.DeployAsync(localProjectAccessFiles, dryRun: true, reconcile: false); + + Assert.Multiple(() => + { + Assert.That(result.Created, Has.Count.EqualTo(1)); + Assert.That(result.Created[0].Sid, Is.EqualTo("deny-access-to-lobby")); + Assert.That(result.Updated, Has.Count.EqualTo(1)); + Assert.That(result.Updated[0].Sid, Is.EqualTo("allow-access-to-cloud-save")); + Assert.That(result.Deleted, Is.Empty); + Assert.That(result.Deployed, Has.Count.EqualTo(1)); + Assert.That(result.Failed, Is.Empty); + }); + } + + [Test] + [TestCase("abc", "Deny", "Player", "urn:ugs:*")] + [TestCase("statement-1", "InvalidEffect", "Player", "urn:ugs:*")] + [TestCase("statement-3", "Deny", "InvalidPrincipal", "urn:ugs:*")] + [TestCase("statement-3", "Deny", "Player", "urn")] + public async Task DeployAsync_InvalidData(string statement, string effect, string principal, string resource) + { + var statements = new List() + { + TestMocks.GetAuthoringStatement(statement, null, effect, principal, resource), + }; + var projectAccessFile = TestMocks.GetProjectAccessFile("path-one", statements); + var localProjectAccessFiles = new List(){projectAccessFile}; + + Mock mockProjectAccessClient = new(); + IProjectAccessConfigValidator projectConfigValidator = new ProjectAccessConfigValidator(); + IProjectAccessMerger projectAccessMerger = new ProjectAccessMerger(); + + var handler = new ProjectAccessDeploymentHandler( + mockProjectAccessClient.Object, + projectConfigValidator, + projectAccessMerger + ); + + var res = await handler.DeployAsync(localProjectAccessFiles, dryRun: false, reconcile: false); + + Assert.IsNotEmpty(res.Failed); + + mockProjectAccessClient + .Verify( + client => client.UpsertAsync(localProjectAccessFiles[0].Statements), + Times.Never); + } + + static List GetRemoteAuthoringStatements() + { + var remoteStatements = new List + { + TestMocks.GetAuthoringStatement( + "allow-access-to-cloud-save", + new List(){"Read"}, + "Allow", + "Player", + "urn:ugs:cloud-save:*") + }; + return remoteStatements; + } + + static List GetLocalProjectAccessFiles() + { + var localProjectAccessFiles = new List() + { + TestMocks.GetProjectAccessFile( + "./file1", + new List() { + TestMocks.GetAuthoringStatement("allow-access-to-cloud-save"), + TestMocks.GetAuthoringStatement("deny-access-to-lobby") + }), + }; + + return localProjectAccessFiles; + } + + static List GetLocalEmptyProjectAccessFiles() + { + return new List() + { + TestMocks.GetProjectAccessFile("path1", new List()) + }; + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Access.UnitTest/Deploy/ProjectAccessDeploymentServiceTests.cs b/Unity.Services.Cli/Unity.Services.Cli.Access.UnitTest/Deploy/ProjectAccessDeploymentServiceTests.cs new file mode 100644 index 0000000..e24296a --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Access.UnitTest/Deploy/ProjectAccessDeploymentServiceTests.cs @@ -0,0 +1,98 @@ +using Moq; +using NUnit.Framework; +using Unity.Services.Access.Authoring.Core.Deploy; +using Unity.Services.Access.Authoring.Core.Model; +using Unity.Services.Access.Authoring.Core.Results; +using Unity.Services.Access.Authoring.Core.Service; +using Unity.Services.Cli.Access.Deploy; +using Unity.Services.Cli.Access.UnitTest.Utils; +using Unity.Services.Cli.Authoring.Input; + +namespace Unity.Services.Cli.Access.UnitTest.Deploy; + +[TestFixture] +public class ProjectAccessDeploymentServiceTests +{ + ProjectAccessDeploymentService? m_DeploymentService; + readonly Mock m_ProjectAccessClientMock = new(); + readonly Mock m_ProjectAccessDeploymentHandlerMock = new(); + readonly Mock m_AccessConfigLoaderMock = new(); + + [SetUp] + public void SetUp() + { + m_ProjectAccessClientMock.Reset(); + m_DeploymentService = new ProjectAccessDeploymentService( + m_ProjectAccessClientMock.Object, + m_ProjectAccessDeploymentHandlerMock.Object, + m_AccessConfigLoaderMock.Object); + + + var statements = new List(){TestMocks.GetAuthoringStatement()}; + var projectAccessFileOne = TestMocks.GetProjectAccessFile("path-one", statements); + var projectAccessFileTwo = TestMocks.GetProjectAccessFile("path-two", new List()); + + var mockLoad = Task.FromResult( + new LoadResult( + loaded: new[] + { + projectAccessFileOne + }, + failed: Array.Empty() )); + + m_AccessConfigLoaderMock + .Setup( + loader => loader.LoadFilesAsync( + It.IsAny>(), + It.IsAny() + )) + .Returns(mockLoad); + + var deployResult = new DeployResult() + { + Created = statements, + Updated = new List(), + Deleted = new List(), + Deployed = new List + { + projectAccessFileTwo + }, + Failed = new List() + }; + + var fromResult = Task.FromResult(deployResult); + + m_ProjectAccessDeploymentHandlerMock.Setup( + handler => handler.DeployAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny() + )) + .Returns(fromResult); + } + + [Test] + public async Task DeployAsync_MapsResult() + { + var input = new DeployInput() + { + Paths = Array.Empty(), + CloudProjectId = string.Empty + }; + var result = await m_DeploymentService!.Deploy( + input, + Array.Empty(), + String.Empty, + string.Empty, + null, + CancellationToken.None); + Assert.Multiple(() => + { + Assert.That(result.Created, Has.Count.EqualTo(1)); + Assert.That(result.Updated, Is.Empty); + Assert.That(result.Deleted, Is.Empty); + Assert.That(result.Deployed, Has.Count.EqualTo(1)); + Assert.That(result.Failed, Is.Empty); + }); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Access.UnitTest/Deploy/ProjectAccessFetchHandlerTests.cs b/Unity.Services.Cli/Unity.Services.Cli.Access.UnitTest/Deploy/ProjectAccessFetchHandlerTests.cs new file mode 100644 index 0000000..d0034a3 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Access.UnitTest/Deploy/ProjectAccessFetchHandlerTests.cs @@ -0,0 +1,376 @@ +using Moq; +using NUnit.Framework; +using Unity.Services.Access.Authoring.Core.Fetch; +using Unity.Services.Access.Authoring.Core.IO; +using Unity.Services.Access.Authoring.Core.Json; +using Unity.Services.Access.Authoring.Core.Model; +using Unity.Services.Access.Authoring.Core.Service; +using Unity.Services.Access.Authoring.Core.Validations; +using Unity.Services.Cli.Access.UnitTest.Utils; + +namespace Unity.Services.Cli.Access.UnitTest.Deploy; + +public class ProjectAccessFetchHandlerTests +{ + [Test] + public async Task FetchAsyncWithReconcile_WithNoExistingContent() + { + var defaultFileWithNoContent = new List() + { + TestMocks.GetProjectAccessFile("./project-statements.ac", new List()) + }; + var remoteStatements = GetRemoteAuthoringStatements(); + + Mock mockProjectAccessClient = new(); + IProjectAccessConfigValidator projectConfigValidator = new ProjectAccessConfigValidator(); + Mock mockFileSystem = new(); + Mock jsonConverter = new(); + + var handler = new ProjectAccessFetchHandler( + mockProjectAccessClient.Object, + mockFileSystem.Object, + jsonConverter.Object, + projectConfigValidator + ); + + mockProjectAccessClient + .Setup(client => client.GetAsync()) + .ReturnsAsync(remoteStatements.ToList()); + + var result = await handler.FetchAsync("./", defaultFileWithNoContent, dryRun: false, reconcile: true); + + Assert.Multiple(() => + { + Assert.That(result.Created, Has.Count.EqualTo(1)); + Assert.That(result.Created[0].Sid, Is.EqualTo("allow-access-to-all-services")); + Assert.That(result.Deleted, Is.Empty); + Assert.That(result.Updated, Is.Empty); + Assert.That(result.Fetched, Has.Count.EqualTo(1)); + Assert.That(result.Failed, Is.Empty); + }); + mockFileSystem + .Verify( + f => f.WriteAllText(It.IsAny(), It.IsAny(), CancellationToken.None), + Times.Exactly(2)); + } + + [Test] + public async Task FetchAsyncWithReconcile_WithOutdatedContent() + { + var defaultFileWithOutdatedContent = new List() + { + TestMocks.GetProjectAccessFile( + "./test_path", + new List() { + TestMocks.GetAuthoringStatement("allow-access-to-all-services"), + TestMocks.GetAuthoringStatement("allow-access-to-cloud-code") + }) + }; + var remoteStatements = GetRemoteAuthoringStatements(); + remoteStatements.Add(TestMocks.GetAuthoringStatement("deny-access")); + + Mock mockProjectAccessClient = new(); + IProjectAccessConfigValidator projectConfigValidator = new ProjectAccessConfigValidator(); + Mock mockFileSystem = new(); + Mock mockJsonConverter = new(); + + var handler = new ProjectAccessFetchHandler( + mockProjectAccessClient.Object, + mockFileSystem.Object, + mockJsonConverter.Object, + projectConfigValidator + ); + + mockProjectAccessClient + .Setup(client => client.GetAsync()) + .ReturnsAsync(remoteStatements.ToList()); + + var result = await handler.FetchAsync("./", defaultFileWithOutdatedContent, dryRun: false, reconcile: true); + + Assert.Multiple(() => + { + Assert.That(result.Updated, Has.Count.EqualTo(1), "One updated"); + Assert.That(result.Updated[0].Sid, Is.EqualTo("allow-access-to-all-services")); + Assert.That(result.Updated[0].Effect, Is.EqualTo("Allow")); + Assert.That(result.Deleted, Has.Count.EqualTo(1), "One deleted"); + Assert.That(result.Deleted[0].Sid, Is.EqualTo("allow-access-to-cloud-code")); + Assert.That(result.Created, Has.Count.EqualTo(1), "One created"); + Assert.That(result.Fetched, Has.Count.EqualTo(2), "One fetched"); + Assert.That(result.Failed, Is.Empty); + }); + + mockFileSystem + .Verify( + f => f.WriteAllText(It.IsAny(), It.IsAny(), CancellationToken.None), + Times.Exactly(2)); + } + + [Test] + public async Task FetchAsyncWithoutReconcile() + { + var defaultFileWithOutdatedContent = new List() + { + TestMocks.GetProjectAccessFile( + "./test_path", + new List() { + TestMocks.GetAuthoringStatement("allow-access-to-all-services"), + TestMocks.GetAuthoringStatement("allow-access-to-cloud-code") + }) + }; + var remoteStatements = GetRemoteAuthoringStatements(); + + Mock mockProjectAccessClient = new(); + IProjectAccessConfigValidator projectConfigValidator = new ProjectAccessConfigValidator(); + Mock mockFileSystem = new(); + Mock jsonConverter = new(); + + var handler = new ProjectAccessFetchHandler( + mockProjectAccessClient.Object, + mockFileSystem.Object, + jsonConverter.Object, + projectConfigValidator + ); + + mockProjectAccessClient + .Setup(client => client.GetAsync()) + .ReturnsAsync(remoteStatements.ToList()); + + var result = await handler.FetchAsync("./", defaultFileWithOutdatedContent, dryRun: false, reconcile: false); + + Assert.Multiple(() => + { + Assert.That(result.Updated, Has.Count.EqualTo(1)); + Assert.That(result.Updated[0].Sid, Is.EqualTo("allow-access-to-all-services")); + Assert.That(result.Updated[0].Effect, Is.EqualTo("Allow")); + Assert.That(result.Deleted, Has.Count.EqualTo(1)); + Assert.That(result.Deleted[0].Sid, Is.EqualTo("allow-access-to-cloud-code")); + Assert.That(result.Created, Is.Empty); + Assert.That(result.Fetched, Has.Count.EqualTo(1)); + Assert.That(result.Failed, Is.Empty); + }); + + mockFileSystem + .Verify( + f => f.WriteAllText(It.IsAny(), It.IsAny(), CancellationToken.None), + Times.Once); + } + + [Test] + public async Task FetchAsyncWithDryRun() + { + var defaultFileWithOutdatedContent = new List() + { + TestMocks.GetProjectAccessFile( + "./test_path", + new List() { + TestMocks.GetAuthoringStatement("allow-access-to-all-services"), + TestMocks.GetAuthoringStatement("allow-access-to-cloud-code") + }) + }; + var remoteStatements = GetRemoteAuthoringStatements(); + + Mock mockProjectAccessClient = new(); + IProjectAccessConfigValidator projectConfigValidator = new ProjectAccessConfigValidator(); + Mock mockFileSystem = new(); + Mock jsonConverter = new(); + + var handler = new ProjectAccessFetchHandler( + mockProjectAccessClient.Object, + mockFileSystem.Object, + jsonConverter.Object, + projectConfigValidator + ); + + mockProjectAccessClient + .Setup(client => client.GetAsync()) + .ReturnsAsync(remoteStatements.ToList()); + + await handler.FetchAsync("./", defaultFileWithOutdatedContent, dryRun: true, reconcile: false); + + mockFileSystem + .Verify( + f => f.WriteAllText(It.IsAny(), It.IsAny(), CancellationToken.None), + Times.Never); + } + + [Test] + public async Task FetchAsyncWithReconcile_NoDefaultFileExists() + { + var emptyFiles = new List(); + var remoteStatements = GetRemoteAuthoringStatements(); + + Mock mockProjectAccessClient = new(); + IProjectAccessConfigValidator projectConfigValidator = new ProjectAccessConfigValidator(); + Mock mockFileSystem = new(); + Mock jsonConverter = new(); + + var handler = new ProjectAccessFetchHandler( + mockProjectAccessClient.Object, + mockFileSystem.Object, + jsonConverter.Object, + projectConfigValidator + ); + + mockProjectAccessClient + .Setup(client => client.GetAsync()) + .ReturnsAsync(remoteStatements.ToList()); + + var result = await handler.FetchAsync("./", emptyFiles, dryRun: false, reconcile: true); + + Assert.Multiple(() => + { + Assert.That(result.Created, Has.Count.EqualTo(1)); + Assert.That(result.Created[0].Sid, Is.EqualTo("allow-access-to-all-services")); + Assert.That(result.Deleted, Is.Empty); + Assert.That(result.Updated, Is.Empty); + Assert.That(result.Failed, Is.Empty); + }); + mockFileSystem + .Verify( + f => f.WriteAllText(It.IsAny(), It.IsAny(), CancellationToken.None), + Times.Once); + } + + [Test] + public Task FetchAsync_DuplicateAuthoringStatements() + { + var defaultFileWithDuplicatedContent = new List() + { + TestMocks.GetProjectAccessFile( + "./test_path", + new List() { + TestMocks.GetAuthoringStatement("allow-access-to-all-services"), + TestMocks.GetAuthoringStatement("allow-access-to-all-services") + }) + }; + var remoteStatements = GetRemoteAuthoringStatements(); + + Mock mockProjectAccessClient = new(); + IProjectAccessConfigValidator projectConfigValidator = new ProjectAccessConfigValidator(); + Mock mockFileSystem = new(); + Mock jsonConverter = new(); + + var handler = new ProjectAccessFetchHandler( + mockProjectAccessClient.Object, + mockFileSystem.Object, + jsonConverter.Object, + projectConfigValidator + ); + + mockProjectAccessClient + .Setup(client => client.GetAsync()) + .ReturnsAsync(remoteStatements.ToList()); + + Assert.ThrowsAsync( + () => handler.FetchAsync("./", defaultFileWithDuplicatedContent, dryRun: false, reconcile: true)); + return Task.CompletedTask; + } + + [Test] + public async Task FetchAsync_DoesNotCreateFileOnUpdate() + { + var files = new List() + { + TestMocks.GetProjectAccessFile( + "./test_path.ca", + new List() { + TestMocks.GetAuthoringStatement("allow-access-to-all-services") + }), + TestMocks.GetProjectAccessFile( + "./test_path_2", + new List() { + TestMocks.GetAuthoringStatement("other") + }) + }; + + var remoteStatements = new List + { + TestMocks.GetAuthoringStatement( + "allow-access-to-all-services"), + TestMocks.GetAuthoringStatement( + "other") + }; + + Mock mockProjectAccessClient = new(); + IProjectAccessConfigValidator projectConfigValidator = new ProjectAccessConfigValidator(); + Mock mockFileSystem = new(); + Mock jsonConverter = new(); + + var handler = new ProjectAccessFetchHandler( + mockProjectAccessClient.Object, + mockFileSystem.Object, + jsonConverter.Object, + projectConfigValidator + ); + + mockProjectAccessClient + .Setup(client => client.GetAsync()) + .ReturnsAsync(remoteStatements.ToList()); + + var rootDirectory = "./"; + var res = await handler.FetchAsync(rootDirectory, files, dryRun: false, reconcile: true); + + var defaultPathName = Path.GetFullPath(Path.Combine(rootDirectory, ProjectAccessFetchHandler.FetchResultName)); + + mockFileSystem.Verify(f => f.WriteAllText(defaultPathName, It.IsAny(), It.IsAny()), Times.Never()); + } + + [Test] + public async Task FetchAsync_ReportsDefaultFileOnCreate() + { + var files = new List() + { + TestMocks.GetProjectAccessFile( + "./test_path.ca", + new List() { + TestMocks.GetAuthoringStatement("allow-access-to-all-services") + }) + }; + + var remoteStatements = new List + { + TestMocks.GetAuthoringStatement( + "allow-access-to-all-services"), + TestMocks.GetAuthoringStatement( + "other") + }; + + Mock mockProjectAccessClient = new(); + IProjectAccessConfigValidator projectConfigValidator = new ProjectAccessConfigValidator(); + Mock mockFileSystem = new(); + Mock jsonConverter = new(); + + var handler = new ProjectAccessFetchHandler( + mockProjectAccessClient.Object, + mockFileSystem.Object, + jsonConverter.Object, + projectConfigValidator + ); + + mockProjectAccessClient + .Setup(client => client.GetAsync()) + .ReturnsAsync(remoteStatements.ToList()); + + var rootDirectory = "./"; + var res = await handler.FetchAsync(rootDirectory, files, dryRun: false, reconcile: true); + + var defaultPathName = Path.GetFullPath(Path.Combine(rootDirectory, ProjectAccessFetchHandler.FetchResultName)); + + var defaultFile = res.Fetched.FirstOrDefault(f => Path.GetFullPath(f.Path) == defaultPathName); + Assert.NotNull(defaultFile); + } + + static List GetRemoteAuthoringStatements() + { + var remoteStatements = new List + { + TestMocks.GetAuthoringStatement( + "allow-access-to-all-services", + null, + "Allow", + "Player", + "urn:ugs:testing:/*") + }; + return remoteStatements; + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Access.UnitTest/Deploy/ProjectAccessFetchServiceTests.cs b/Unity.Services.Cli/Unity.Services.Cli.Access.UnitTest/Deploy/ProjectAccessFetchServiceTests.cs new file mode 100644 index 0000000..b408f7a --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Access.UnitTest/Deploy/ProjectAccessFetchServiceTests.cs @@ -0,0 +1,97 @@ +using Moq; +using NUnit.Framework; +using Unity.Services.Access.Authoring.Core.Fetch; +using Unity.Services.Access.Authoring.Core.Model; +using Unity.Services.Access.Authoring.Core.Results; +using Unity.Services.Access.Authoring.Core.Service; +using Unity.Services.Cli.Access.Deploy; +using Unity.Services.Cli.Access.UnitTest.Utils; +using Unity.Services.Cli.Authoring.Input; + +namespace Unity.Services.Cli.Access.UnitTest.Deploy; + +[TestFixture] + +public class ProjectAccessFetchServiceTests +{ + ProjectAccessFetchService? m_FetchService; + readonly Mock m_ProjectAccessClientMock = new(); + readonly Mock m_ProjectAccessFetchHandlerMock = new(); + readonly Mock m_AccessConfigLoaderMock = new(); + + [SetUp] + public void SetUp() + { + m_ProjectAccessClientMock.Reset(); + m_FetchService = new ProjectAccessFetchService( + m_ProjectAccessClientMock.Object, + m_ProjectAccessFetchHandlerMock.Object, + m_AccessConfigLoaderMock.Object); + + + var statements = new List(){TestMocks.GetAuthoringStatement()}; + var projectAccessFileOne = TestMocks.GetProjectAccessFile("path-one", statements); + var projectAccessFileTwo = TestMocks.GetProjectAccessFile("path-two", new List()); + + var mockLoad = Task.FromResult( + new LoadResult( + loaded: new[] + { + projectAccessFileOne + }, + failed: Array.Empty() )); + + m_AccessConfigLoaderMock + .Setup( + loader => loader.LoadFilesAsync( + It.IsAny>(), + It.IsAny() + )) + .Returns(mockLoad); + + var fetchResult = new FetchResult(statements, + new List(), + new List(), + new List(){projectAccessFileTwo}, + new List()); + + var fromResult = Task.FromResult(fetchResult); + + m_ProjectAccessFetchHandlerMock.Setup( + handler => handler.FetchAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny(), + CancellationToken.None + )) + .Returns(fromResult); + } + + + [Test] + public async Task FetchAsync_MapsResult() + { + var input = new FetchInput() + { + Path = "", + CloudProjectId = string.Empty + }; + + var result = await m_FetchService!.FetchAsync( + input, + Array.Empty(), + String.Empty, + string.Empty, + null, + CancellationToken.None); + Assert.Multiple(() => + { + Assert.That(result.Created, Has.Count.EqualTo(1)); + Assert.That(result.Updated, Is.Empty); + Assert.That(result.Deleted, Is.Empty); + Assert.That(result.Fetched, Has.Count.EqualTo(1)); + Assert.That(result.Failed, Is.Empty); + }); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Access.UnitTest/Model/ProjectAccessFileExtensionTests.cs b/Unity.Services.Cli/Unity.Services.Cli.Access.UnitTest/Model/ProjectAccessFileExtensionTests.cs new file mode 100644 index 0000000..ced2690 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Access.UnitTest/Model/ProjectAccessFileExtensionTests.cs @@ -0,0 +1,158 @@ +using NUnit.Framework; +using Unity.Services.Access.Authoring.Core.Model; + +namespace Unity.Services.Cli.Access.UnitTest.Model; + +[TestFixture] +public class ProjectAccessFileExtensionTests +{ + [Test] + public Task ProjectAccessFileExtension_RemoveStatements() + { + var projectAccessFile = GetLocalProjectAccessFile(); + var statementsToRemove = GetStatementsToRemove(); + + projectAccessFile.RemoveStatements(statementsToRemove); + Assert.That(projectAccessFile.Statements, Has.Count.EqualTo(1)); + + return Task.CompletedTask; + } + + [Test] + public Task ProjectAccessFileExtension_UpdateStatements() + { + var projectAccessFile = GetLocalProjectAccessFile(); + var statementsToUpdate = GetStatementsToUpdate(); + + projectAccessFile.UpdateStatements(statementsToUpdate); + Assert.That(projectAccessFile.Statements, Has.Count.EqualTo(2)); + Assert.That(projectAccessFile.Statements[1].Version, Is.EqualTo("3.0")); + + return Task.CompletedTask; + } + + [Test] + public Task ProjectAccessFileExtension_UpdateOrCreateStatementsShouldUpdate() + { + var projectAccessFile = GetLocalProjectAccessFile(); + var statementsToUpdate = GetStatementsToUpdate(); + + projectAccessFile.UpdateOrCreateStatements(statementsToUpdate); + Assert.That(projectAccessFile.Statements, Has.Count.EqualTo(2)); + Assert.That(projectAccessFile.Statements[1].Version, Is.EqualTo("3.0")); + + return Task.CompletedTask; + } + + [Test] + public Task ProjectAccessFileExtension_UpdateOrCreateStatementsShouldCreate() + { + var projectAccessFile = GetLocalProjectAccessFile(); + var statementsToCreate = GetStatementsToCreate(); + + projectAccessFile.UpdateOrCreateStatements(statementsToCreate); + Assert.That(projectAccessFile.Statements, Has.Count.EqualTo(3)); + Assert.That(projectAccessFile.Statements[2].Sid, Is.EqualTo("allow-access-to-economy")); + + return Task.CompletedTask; + } + + static IProjectAccessFile GetLocalProjectAccessFile() + { + return new ProjectAccessFile() + { + Name = "file1", + Path = "path1", + Statements = new List() + { + new AccessControlStatement() + { + Sid = "allow-access-to-cloud-save", + Action = new List() + { + "*" + }, + Effect = "Allow", + Principal = "Player", + Resource = "urn:ugs:cloud-save:*", + ExpiresAt = DateTime.MaxValue, + Version = "100.0" + }, + new AccessControlStatement() + { + Sid = "deny-access-to-lobby", + Action = new List() + { + "*" + }, + Effect = "Deny", + Principal = "Player", + Resource = "urn:ugs:lobby:*", + ExpiresAt = DateTime.MaxValue, + Version = "2.0" + } + } + }; + } + + + static IReadOnlyList GetStatementsToRemove() + { + return new List() + { + new AccessControlStatement() + { + Sid = "deny-access-to-lobby", + Action = new List() + { + "*" + }, + Effect = "Deny", + Principal = "Player", + Resource = "urn:ugs:lobby:*", + ExpiresAt = DateTime.MaxValue, + Version = "2.0" + } + }; + } + + static IReadOnlyList GetStatementsToUpdate() + { + return new List() + { + new AccessControlStatement() + { + Sid = "deny-access-to-lobby", + Action = new List() + { + "*" + }, + Effect = "Deny", + Principal = "Player", + Resource = "urn:ugs:lobby:*", + ExpiresAt = DateTime.MaxValue, + Version = "3.0" + } + }; + } + + static IReadOnlyList GetStatementsToCreate() + { + return new List() + { + new AccessControlStatement() + { + Sid = "allow-access-to-economy", + Action = new List() + { + "*" + }, + Effect = "Allow", + Principal = "Player", + Resource = "urn:ugs:economy:*", + ExpiresAt = DateTime.MaxValue, + Version = "1.0" + } + }; + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Access.UnitTest/Service/AccessServiceTests.cs b/Unity.Services.Cli/Unity.Services.Cli.Access.UnitTest/Service/AccessServiceTests.cs index 0f5f845..74ddf10 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.Access.UnitTest/Service/AccessServiceTests.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.Access.UnitTest/Service/AccessServiceTests.cs @@ -40,7 +40,7 @@ public async Task SetUp() m_ProjectPolicyApi.Reset(); m_PlayerPolicyApi.Reset(); - m_DeleteOptionsFile = await GetFileInfoObjectAsync("tmp-delete-options.json", TestValues.deleteOptionsJson); + m_DeleteOptionsFile = await GetFileInfoObjectAsync("tmp-delete-options.json", TestValues.DeleteOptionsJson); m_PolicyFile = await GetFileInfoObjectAsync("tmp-policy.json", TestValues.PolicyJson); m_WrongFormattedFile = await GetFileInfoObjectAsync("tmp-wrong-formatted.json", "{\"invalidProperty\":[]}"); @@ -58,9 +58,9 @@ public async Task SetUp() [OneTimeTearDown] public void OneTimeTearDown() { - m_PolicyFile.Delete(); - m_DeleteOptionsFile.Delete(); - m_WrongFormattedFile.Delete(); + m_PolicyFile?.Delete(); + m_DeleteOptionsFile?.Delete(); + m_WrongFormattedFile?.Delete(); } [Test] @@ -131,7 +131,10 @@ public async Task UpsertPolicyAsync_Valid() m_ProjectPolicyApi.Setup(a => a.UpsertPolicyAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), CancellationToken.None)); - await m_AccessService!.UpsertPolicyAsync(TestValues.ValidProjectId, TestValues.ValidEnvironmentId, m_PolicyFile, + await m_AccessService!.UpsertPolicyAsync( + TestValues.ValidProjectId, + TestValues.ValidEnvironmentId, + m_PolicyFile!, CancellationToken.None); m_ProjectPolicyApi.Verify( @@ -148,7 +151,10 @@ public async Task UpsertPolicyAsync_Valid() public void UpsertPolicyAsync_InvalidInput() { Assert.ThrowsAsync( - () => m_AccessService!.UpsertPolicyAsync(TestValues.ValidProjectId, TestValues.ValidEnvironmentId, m_WrongFormattedFile, + () => m_AccessService!.UpsertPolicyAsync( + TestValues.ValidProjectId, + TestValues.ValidEnvironmentId, + m_WrongFormattedFile!, CancellationToken.None)); } @@ -158,7 +164,11 @@ public async Task UpsertPlayerPolicyAsync_Valid() m_PlayerPolicyApi.Setup(a => a.UpsertPlayerPolicyAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), CancellationToken.None)); - await m_AccessService!.UpsertPlayerPolicyAsync(TestValues.ValidProjectId, TestValues.ValidEnvironmentId, TestValues.ValidPlayerId, m_PolicyFile, + await m_AccessService!.UpsertPlayerPolicyAsync( + TestValues.ValidProjectId, + TestValues.ValidEnvironmentId, + TestValues.ValidPlayerId, + m_PolicyFile!, CancellationToken.None); m_PlayerPolicyApi.Verify( @@ -176,8 +186,12 @@ public async Task UpsertPlayerPolicyAsync_Valid() public void UpsertPlayerPolicyAsync_InvalidInput() { Assert.ThrowsAsync( - () => m_AccessService!.UpsertPlayerPolicyAsync(TestValues.ValidProjectId, TestValues.ValidEnvironmentId, TestValues.ValidPlayerId, - m_WrongFormattedFile, CancellationToken.None)); + () => m_AccessService!.UpsertPlayerPolicyAsync( + TestValues.ValidProjectId, + TestValues.ValidEnvironmentId, + TestValues.ValidPlayerId, + m_WrongFormattedFile!, + CancellationToken.None)); } [Test] @@ -185,7 +199,10 @@ public async Task DeletePolicyStatementsAsync_Valid() { m_ProjectPolicyApi.Setup(a => a.DeletePolicyStatementsAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), CancellationToken.None)); - await m_AccessService!.DeletePolicyStatementsAsync(TestValues.ValidProjectId, TestValues.ValidEnvironmentId, m_DeleteOptionsFile, + await m_AccessService!.DeletePolicyStatementsAsync( + TestValues.ValidProjectId, + TestValues.ValidEnvironmentId, + m_DeleteOptionsFile!, CancellationToken.None); m_ProjectPolicyApi.Verify( @@ -202,8 +219,11 @@ public async Task DeletePolicyStatementsAsync_Valid() public void DeletePolicyStatementsAsync_InvalidInput() { Assert.ThrowsAsync( - () => m_AccessService!.DeletePolicyStatementsAsync(TestValues.ValidProjectId, TestValues.ValidEnvironmentId, - m_WrongFormattedFile, CancellationToken.None)); + () => m_AccessService!.DeletePolicyStatementsAsync( + TestValues.ValidProjectId, + TestValues.ValidEnvironmentId, + m_WrongFormattedFile!, + CancellationToken.None)); } [Test] @@ -211,7 +231,11 @@ public async Task DeletePlayerPolicyStatementsAsync_Valid() { m_PlayerPolicyApi.Setup(a => a.DeletePlayerPolicyStatementsAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), CancellationToken.None)); - await m_AccessService!.DeletePlayerPolicyStatementsAsync(TestValues.ValidProjectId, TestValues.ValidEnvironmentId, TestValues.ValidPlayerId, m_DeleteOptionsFile, + await m_AccessService!.DeletePlayerPolicyStatementsAsync( + TestValues.ValidProjectId, + TestValues.ValidEnvironmentId, + TestValues.ValidPlayerId, + m_DeleteOptionsFile!, CancellationToken.None); m_PlayerPolicyApi.Verify( @@ -229,7 +253,53 @@ public async Task DeletePlayerPolicyStatementsAsync_Valid() public void DeletePlayerPolicyStatementsAsync_InvalidInput() { Assert.ThrowsAsync( - () => m_AccessService!.DeletePlayerPolicyStatementsAsync(TestValues.ValidProjectId, TestValues.ValidEnvironmentId, TestValues.ValidPlayerId, - m_WrongFormattedFile, CancellationToken.None)); + () => m_AccessService!.DeletePlayerPolicyStatementsAsync( + TestValues.ValidProjectId, + TestValues.ValidEnvironmentId, + TestValues.ValidPlayerId, + m_WrongFormattedFile!, + CancellationToken.None)); + } + + [Test] + public async Task UpsertProjectAccessCaCAsync_Valid() + { + var statements = new List() + { + TestMocks.GetStatement() + }; + m_ProjectPolicyApi.Setup(a => a.UpsertPolicyAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), CancellationToken.None)); + + await m_AccessService!.UpsertProjectAccessCaCAsync(TestValues.ValidProjectId, TestValues.ValidEnvironmentId, TestMocks.GetPolicy(statements), + CancellationToken.None); + + m_ProjectPolicyApi.Verify( + a => a.UpsertPolicyAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Once); + } + + [Test] + public async Task DeleteProjectAccessCaCAsync_Valid() + { + var statementIDs = new List(){"statement-1"}; + m_ProjectPolicyApi.Setup(a => a.DeletePolicyStatementsAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), CancellationToken.None)); + + await m_AccessService!.DeleteProjectAccessCaCAsync(TestValues.ValidProjectId, TestValues.ValidEnvironmentId, TestMocks.GetDeleteOptions(statementIDs), + CancellationToken.None); + + m_ProjectPolicyApi.Verify( + a => a.DeletePolicyStatementsAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Once); } } diff --git a/Unity.Services.Cli/Unity.Services.Cli.Access.UnitTest/Utils/TestMocks.cs b/Unity.Services.Cli/Unity.Services.Cli.Access.UnitTest/Utils/TestMocks.cs index 09c4c3b..10e1608 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.Access.UnitTest/Utils/TestMocks.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.Access.UnitTest/Utils/TestMocks.cs @@ -1,19 +1,50 @@ +using Unity.Services.Access.Authoring.Core.Model; using Unity.Services.Gateway.AccessApiV1.Generated.Model; namespace Unity.Services.Cli.Access.UnitTest.Utils; public class TestMocks { - public static Statement GetStatement() + public static Statement GetStatement(string sid = "statement-1") { List action = new List(); action.Add("*"); - Statement statement = new Statement(sid: "statement-1", action: action, effect: "Deny", principal: "Player", + Statement statement = new Statement(sid: sid, action: action, effect: "Deny", principal: "Player", resource: "urn:ugs:*"); return statement; } + public static Policy GetPolicy(List statements) + { + var policy = new Policy(statements); + return policy; + } + + public static AccessControlStatement GetAuthoringStatement( + string sid = "statement-1", + List? action = null, + string effect = "Deny", + string principal = "Player", + string resource = "urn:ugs:*" + ) + { + action ??= new List() + { + "*" + }; + AccessControlStatement statement = new AccessControlStatement + { + Sid = sid, + Action = action, + Effect = effect, + Principal = principal, + Resource = resource + }; + + return statement; + } + public static PlayerPolicy GetPlayerPolicy() { List statements = new List(); @@ -32,4 +63,20 @@ public static PlayerPolicies GetPlayerPolicies() return playerPolicies; } + + public static DeleteOptions GetDeleteOptions(List statementIDs) + { + var deleteOptions = new DeleteOptions(statementIDs); + return deleteOptions; + } + + public static ProjectAccessFile GetProjectAccessFile(string path, List statements) + { + return new ProjectAccessFile() + { + Path = path, + Name = Path.GetFileName(path), + Statements = statements + }; + } } diff --git a/Unity.Services.Cli/Unity.Services.Cli.Access.UnitTest/Utils/TestValues.cs b/Unity.Services.Cli/Unity.Services.Cli.Access.UnitTest/Utils/TestValues.cs index 8035abc..adeeb5d 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.Access.UnitTest/Utils/TestValues.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.Access.UnitTest/Utils/TestValues.cs @@ -18,5 +18,5 @@ static class TestValues public const string PolicyJson = "{\"statements\":[{\"Sid\":\"Statement-1\",\"Action\":[\"*\"],\"Resource\":\"urn:ugs:*\",\"Principal\":\"Player\",\"Effect\":\"Deny\"}]}"; - public const string deleteOptionsJson = "{\"statementIDs\":[\"statement-1\"]}"; + public const string DeleteOptionsJson = "{\"statementIDs\":[\"statement-1\"]}"; } diff --git a/Unity.Services.Cli/Unity.Services.Cli.Access/AccessModule.cs b/Unity.Services.Cli/Unity.Services.Cli.Access/AccessModule.cs index eb941d2..ed168ed 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.Access/AccessModule.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.Access/AccessModule.cs @@ -12,6 +12,18 @@ using Unity.Services.Cli.Common.Utils; using Unity.Services.Gateway.AccessApiV1.Generated.Api; using Unity.Services.Gateway.AccessApiV1.Generated.Client; +using Unity.Services.Access.Authoring.Core.Deploy; +using Unity.Services.Access.Authoring.Core.Fetch; +using Unity.Services.Access.Authoring.Core.Json; +using Unity.Services.Access.Authoring.Core.Model; +using Unity.Services.Access.Authoring.Core.Validations; +using Unity.Services.Access.Authoring.Core.IO; +using Unity.Services.Access.Authoring.Core.Service; +using Unity.Services.Cli.Access.Deploy; +using Unity.Services.Cli.Access.IO; +using Unity.Services.Cli.Access.Models; +using Unity.Services.Cli.Authoring.Handlers; +using Unity.Services.Cli.Authoring.Service; namespace Unity.Services.Cli.Access; @@ -120,7 +132,8 @@ public AccessModule() UpsertProjectPolicyCommand, UpsertPlayerPolicyCommand, DeleteProjectPolicyStatementsCommand, - DeletePlayerPolicyStatementsCommand + DeletePlayerPolicyStatementsCommand, + ModuleRootCommand.AddNewFileCommand("ProjectAccess"), }; ModuleRootCommand.AddAlias("ac"); @@ -136,10 +149,31 @@ public static void RegisterServices(HostBuilderContext hostBuilderContext, IServ BasePath = EndpointHelper.GetCurrentEndpointFor() }; config.DefaultHeaders.SetXClientIdHeader(); + // API Clients serviceCollection.AddSingleton(new ProjectPolicyApi(config)); serviceCollection.AddSingleton(new PlayerPolicyApi(config)); - + + serviceCollection.AddTransient(); + serviceCollection.AddTransient(); + serviceCollection.AddTransient(); + serviceCollection.AddTransient(); + serviceCollection.AddTransient(); + serviceCollection.AddTransient(); + + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(s => s.GetRequiredService()); + + serviceCollection.AddSingleton(); + + serviceCollection.AddTransient(); + serviceCollection.AddTransient(); + serviceCollection.AddSingleton(); + + serviceCollection.AddTransient(); + serviceCollection.AddTransient(); + serviceCollection.AddTransient(); + } } diff --git a/Unity.Services.Cli/Unity.Services.Cli.Access/Deploy/AccessAuthoringResult.cs b/Unity.Services.Cli/Unity.Services.Cli.Access/Deploy/AccessAuthoringResult.cs new file mode 100644 index 0000000..79dedf7 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Access/Deploy/AccessAuthoringResult.cs @@ -0,0 +1,86 @@ +using Unity.Services.Access.Authoring.Core.Model; +using Unity.Services.Cli.Authoring.Model; +using Unity.Services.Cli.Authoring.Model.TableOutput; +using Unity.Services.DeploymentApi.Editor; + +namespace Unity.Services.Cli.Access.Deploy; + +class AccessDeploymentResult : DeploymentResult +{ + public AccessDeploymentResult( + IReadOnlyList updated, + IReadOnlyList deleted, + IReadOnlyList created, + IReadOnlyList authored, + IReadOnlyList failed, + bool dryRun = false) + : base( + updated, + deleted, + created, + authored, + failed, + dryRun) + { + } + + public override TableContent ToTable() + { + return AccessControlResToTable(this); + } + + public static TableContent AccessControlResToTable(AuthorResult res) + { + var table = new TableContent + { + IsDryRun = res.DryRun + }; + + foreach (var deploymentItem in res.Authored) + { + var file = (IProjectAccessFile)deploymentItem; + table.AddRow(RowContent.ToRow(file)); + foreach (var statement in file.Statements) + table.AddRow(RowContent.ToRow(statement)); + } + + foreach (var deleted in res.Deleted) + { + table.AddRow(RowContent.ToRow(deleted)); + } + + foreach (var deploymentItem in res.Failed) + { + var file = (IProjectAccessFile)deploymentItem; + table.AddRow(RowContent.ToRow(file)); + foreach (var statement in file.Statements) + table.AddRow(RowContent.ToRow(statement)); + } + + return table; + } +} + +class AccessFetchResult : FetchResult +{ + public AccessFetchResult( + IReadOnlyList updated, + IReadOnlyList deleted, + IReadOnlyList created, + IReadOnlyList authored, + IReadOnlyList failed, + bool dryRun = false) : base( + updated, + deleted, + created, + authored, + failed, + dryRun) + { + } + + public override TableContent ToTable() + { + return AccessDeploymentResult.AccessControlResToTable(this); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Access/Deploy/AccessConfigLoader.cs b/Unity.Services.Cli/Unity.Services.Cli.Access/Deploy/AccessConfigLoader.cs new file mode 100644 index 0000000..0bffc9c --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Access/Deploy/AccessConfigLoader.cs @@ -0,0 +1,62 @@ +using Newtonsoft.Json; +using System.IO.Abstractions; +using Unity.Services.Access.Authoring.Core.Json; +using Unity.Services.Access.Authoring.Core.Model; +using Unity.Services.Cli.Authoring.Model; +using Unity.Services.DeploymentApi.Editor; +using IFileSystem = Unity.Services.Access.Authoring.Core.IO.IFileSystem; + +namespace Unity.Services.Cli.Access.Deploy; + +class AccessConfigLoader : IAccessConfigLoader +{ + readonly IPath m_Path; + readonly IFileSystem m_FileSystem; + readonly IJsonConverter m_JsonConverter; + + + public AccessConfigLoader(IFileSystem fileSystem, IPath path, IJsonConverter jsonConverter) + { + m_Path = path; + m_FileSystem = fileSystem; + m_JsonConverter = jsonConverter; + } + + public async Task LoadFilesAsync(IReadOnlyList filePaths, CancellationToken token) + { + var loaded = new List(); + var failed = new List(); + + foreach (var filePath in filePaths) + { + var name = m_Path.GetFileName(filePath); + var file = new ProjectAccessFile + { + Name = name, + Path = filePath + }; + + try + { + var fileText = await m_FileSystem.ReadAllText(filePath, token); + var content = m_JsonConverter.DeserializeObject(fileText); + if (content is null) + { + throw new JsonException($"{filePath} is not a valid resource"); + } + + file.Statements = content.ToAuthoringStatements(file, new ProjectAccessParser()); + + loaded.Add(file); + file.Status = new DeploymentStatus(Statuses.Loaded, ""); + } + catch (Exception ex) + { + file.Status = new DeploymentStatus(Statuses.FailedToRead, ex.Message, SeverityLevel.Error); + failed.Add(file); + } + } + + return new LoadResult(loaded, failed); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Access/Deploy/IAccessConfigLoader.cs b/Unity.Services.Cli/Unity.Services.Cli.Access/Deploy/IAccessConfigLoader.cs new file mode 100644 index 0000000..f5da896 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Access/Deploy/IAccessConfigLoader.cs @@ -0,0 +1,8 @@ +namespace Unity.Services.Cli.Access.Deploy; + +public interface IAccessConfigLoader +{ + Task LoadFilesAsync( + IReadOnlyList filePaths, + CancellationToken token); +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Access/Deploy/JsonConverter.cs b/Unity.Services.Cli/Unity.Services.Cli.Access/Deploy/JsonConverter.cs new file mode 100644 index 0000000..080dada --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Access/Deploy/JsonConverter.cs @@ -0,0 +1,35 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Newtonsoft.Json.Serialization; +using Unity.Services.Access.Authoring.Core.Json; + +namespace Unity.Services.Cli.Access.Deploy; + +public class JsonConverter : IJsonConverter +{ + static JsonSerializerSettings GetSettings(bool matchCamelCaseFieldName = false) + { + var contractResolver = matchCamelCaseFieldName ? new CamelCasePropertyNamesContractResolver() : new DefaultContractResolver(); + + return new JsonSerializerSettings() + { + ContractResolver = contractResolver, + Formatting = Formatting.Indented, + Converters = { new StringEnumConverter() }, + NullValueHandling = NullValueHandling.Ignore, + DefaultValueHandling = DefaultValueHandling.Ignore + }; + } + + public T DeserializeObject(string value, bool matchCamelCaseFieldName = false) + { + var settings = GetSettings(matchCamelCaseFieldName); + return JsonConvert.DeserializeObject(value, settings)!; + } + + public string SerializeObject(T obj) + { + var settings = GetSettings(); + return JsonConvert.SerializeObject(obj, settings); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Access/Deploy/LoadResult.cs b/Unity.Services.Cli/Unity.Services.Cli.Access/Deploy/LoadResult.cs new file mode 100644 index 0000000..2df0dd1 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Access/Deploy/LoadResult.cs @@ -0,0 +1,15 @@ +using Unity.Services.Access.Authoring.Core.Model; + +namespace Unity.Services.Cli.Access.Deploy; + +public class LoadResult +{ + public IReadOnlyList Loaded { get; } + public IReadOnlyList Failed { get; } + + public LoadResult(IReadOnlyList loaded, IReadOnlyList failed) + { + Loaded = loaded; + Failed = failed; + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Access/Deploy/ProjectAccessClient.cs b/Unity.Services.Cli/Unity.Services.Cli.Access/Deploy/ProjectAccessClient.cs new file mode 100644 index 0000000..d82f6ea --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Access/Deploy/ProjectAccessClient.cs @@ -0,0 +1,114 @@ +using Unity.Services.Access.Authoring.Core.Model; +using Unity.Services.Access.Authoring.Core.Service; +using Unity.Services.Cli.Access.Service; +using Unity.Services.Gateway.AccessApiV1.Generated.Model; + +namespace Unity.Services.Cli.Access.Deploy; + +public class ProjectAccessClient : IProjectAccessClient +{ + public string ProjectId { get; private set; } + public string EnvironmentId { get; private set; } + public CancellationToken CancellationToken { get; private set; } + + readonly IAccessService m_Service; + + public void Initialize(string environmentId, string projectId, CancellationToken cancellationToken) + { + ProjectId = projectId; + EnvironmentId = environmentId; + CancellationToken = cancellationToken; + } + + internal ProjectAccessClient( + IAccessService service, + string projectId, + string environmentId, + CancellationToken cancellationToken) + { + m_Service = service; + ProjectId = projectId; + EnvironmentId = environmentId; + CancellationToken = cancellationToken; + } + + public ProjectAccessClient(IAccessService service) + { + m_Service = service; + ProjectId = string.Empty; + EnvironmentId = string.Empty; + CancellationToken = CancellationToken.None; + } + + public async Task> GetAsync() + { + var policy = await m_Service.GetPolicyAsync(ProjectId, EnvironmentId, CancellationToken); + + return (policy.Statements == null || policy.Statements?.Count == 0) ? new List() : GetAuthoringStatementsFromPolicy(policy); + } + + public async Task UpsertAsync(IReadOnlyList authoringStatements) + { + var policy = GetPolicyFromAuthoringStatements(authoringStatements); + await m_Service.UpsertProjectAccessCaCAsync( + ProjectId, + EnvironmentId, + policy, + CancellationToken); + } + + public async Task DeleteAsync(IReadOnlyList authoringStatements) + { + var deleteOptions = GetDeleteOptionsFromAuthoringStatements(authoringStatements); + await m_Service.DeleteProjectAccessCaCAsync( + ProjectId, + EnvironmentId, + deleteOptions, + CancellationToken); + } + + static List GetAuthoringStatementsFromPolicy(Policy policy) + { + var authoringStatements = policy.Statements.Select( + s => new AccessControlStatement() + { + Name = s.Sid, + Path = "Remote", + Sid = s.Sid, + Action = s.Action, + Effect = s.Effect, + Principal = s.Principal, + Resource = s.Resource, + ExpiresAt = s.ExpiresAt, + Version = s._Version, + }) + .ToList(); + + return authoringStatements; + } + + static Policy GetPolicyFromAuthoringStatements(IReadOnlyList authoringStatements) + { + var statements = authoringStatements.Select( + s => new Statement( + sid: s.Sid, + action: s.Action, + effect: s.Effect, + principal: s.Principal, + resource: s.Resource, + expiresAt: s.ExpiresAt, + version: s.Version + )) + .ToList(); + + var policy = new Policy(statements); + return policy; + } + + static DeleteOptions GetDeleteOptionsFromAuthoringStatements(IReadOnlyList authoringStatements) + { + var statementIDs = authoringStatements.Select(x => x.Sid).ToList(); + var deleteOptions = new DeleteOptions(statementIDs); + return deleteOptions; + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Access/Deploy/ProjectAccessDeploymentService.cs b/Unity.Services.Cli/Unity.Services.Cli.Access/Deploy/ProjectAccessDeploymentService.cs new file mode 100644 index 0000000..7244939 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Access/Deploy/ProjectAccessDeploymentService.cs @@ -0,0 +1,70 @@ +using Spectre.Console; +using Unity.Services.Access.Authoring.Core.Deploy; +using Unity.Services.Access.Authoring.Core.Model; +using Unity.Services.Access.Authoring.Core.Service; +using Unity.Services.Cli.Authoring.Input; +using Unity.Services.Cli.Authoring.Model; +using Unity.Services.Cli.Authoring.Service; + +namespace Unity.Services.Cli.Access.Deploy; + +public class ProjectAccessDeploymentService : IDeploymentService +{ + readonly string m_ServiceType; + readonly string m_ServiceName; + readonly string m_DeployFileExtension; + readonly IProjectAccessClient m_ProjectAccessClient; + readonly IProjectAccessDeploymentHandler m_DeploymentHandler; + readonly IAccessConfigLoader m_AccessConfigConfigLoader; + + public ProjectAccessDeploymentService( + IProjectAccessClient projectAccessClient, + IProjectAccessDeploymentHandler projectAccessDeploymentHandler, + IAccessConfigLoader projectAccessConfigLoader + ) + { + m_ProjectAccessClient = projectAccessClient; + m_DeploymentHandler = projectAccessDeploymentHandler; + m_AccessConfigConfigLoader = projectAccessConfigLoader; + m_ServiceType = "Access"; + m_ServiceName = "access"; + m_DeployFileExtension = ".ac"; + } + + public string ServiceType => m_ServiceType; + + public string ServiceName => m_ServiceName; + + public IReadOnlyList FileExtensions => new[] + { + m_DeployFileExtension + }; + + public async Task Deploy( + DeployInput deployInput, + IReadOnlyList filePaths, + string projectId, + string environmentId, + StatusContext? loadingContext, + CancellationToken cancellationToken) + { + m_ProjectAccessClient.Initialize(environmentId, projectId, cancellationToken); + var files = await m_AccessConfigConfigLoader.LoadFilesAsync(filePaths, cancellationToken); + var loadedFiles = files.Loaded; + var failedFiles = files.Failed; + + loadingContext?.Status($"Deploying {m_ServiceType} Files..."); + + var deployStatusList = await m_DeploymentHandler.DeployAsync( + loadedFiles, + deployInput.DryRun, + deployInput.Reconcile); + + return new AccessDeploymentResult( + deployStatusList.Updated, + deployStatusList.Deleted, + deployStatusList.Created, + deployStatusList.Deployed.Select(d => (ProjectAccessFile)d).ToList(), + deployStatusList.Failed.Concat(failedFiles).Select(d => (ProjectAccessFile)d).ToList()); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Access/Deploy/ProjectAccessFetchService.cs b/Unity.Services.Cli/Unity.Services.Cli.Access/Deploy/ProjectAccessFetchService.cs new file mode 100644 index 0000000..bedb54a --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Access/Deploy/ProjectAccessFetchService.cs @@ -0,0 +1,66 @@ +using Spectre.Console; +using Unity.Services.Access.Authoring.Core.Fetch; +using Unity.Services.Access.Authoring.Core.Model; +using Unity.Services.Access.Authoring.Core.Service; +using Unity.Services.Cli.Authoring.Input; +using Unity.Services.Cli.Authoring.Model; +using Unity.Services.Cli.Authoring.Service; + +namespace Unity.Services.Cli.Access.Deploy; + +public class ProjectAccessFetchService : IFetchService +{ + readonly IProjectAccessFetchHandler m_FetchHandler; + readonly IProjectAccessClient m_ProjectAccessClient; + readonly IAccessConfigLoader m_AccessConfigConfigLoader; + readonly string m_ServiceType; + readonly string m_ServiceName; + readonly string m_FileExtension; + + public ProjectAccessFetchService( + IProjectAccessClient projectAccessClient, + IProjectAccessFetchHandler projectAccessFetchHandler, + IAccessConfigLoader projectAccessConfigLoader) + { + m_ProjectAccessClient = projectAccessClient; + m_FetchHandler = projectAccessFetchHandler; + m_AccessConfigConfigLoader = projectAccessConfigLoader; + m_ServiceType = "Access"; + m_ServiceName = "access"; + m_FileExtension = ".ac"; + } + + public string ServiceType => m_ServiceType; + public string ServiceName => m_ServiceName; + public IReadOnlyList FileExtensions => new[] + { + m_FileExtension + }; + public async Task FetchAsync( + FetchInput input, + IReadOnlyList filePaths, + string projectId, + string environmentId, + StatusContext? loadingContext, + CancellationToken cancellationToken) + { + m_ProjectAccessClient.Initialize(environmentId, projectId, cancellationToken); + var loadResult = await m_AccessConfigConfigLoader.LoadFilesAsync(filePaths, cancellationToken); + var configFiles = loadResult.Loaded.ToList(); + + loadingContext?.Status($"Fetching {ServiceType} Files..."); + var fetchStatusList = await m_FetchHandler.FetchAsync( + input.Path, + configFiles, + input.DryRun, + input.Reconcile, + cancellationToken); + + return new AccessFetchResult( + updated: fetchStatusList.Updated, + deleted: fetchStatusList.Deleted, + created: fetchStatusList.Created, + authored: fetchStatusList.Fetched.Select(d => (ProjectAccessFile)d).ToList(), + failed: fetchStatusList.Failed.Select(d => (ProjectAccessFile)d).ToList()); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Access/IO/FileSystem.cs b/Unity.Services.Cli/Unity.Services.Cli.Access/IO/FileSystem.cs new file mode 100644 index 0000000..1d31e6d --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Access/IO/FileSystem.cs @@ -0,0 +1,22 @@ +using Unity.Services.Access.Authoring.Core.IO; + +namespace Unity.Services.Cli.Access.IO; + +public class FileSystem : IFileSystem +{ + public Task ReadAllText(string path, CancellationToken token = default(CancellationToken)) + { + return File.ReadAllTextAsync(path, token); + } + + public Task WriteAllText(string path, string contents, CancellationToken token = default(CancellationToken)) + { + return File.WriteAllTextAsync(path, contents, token); + } + + public Task Delete(string path, CancellationToken token = default(CancellationToken)) + { + File.Delete(path); + return Task.CompletedTask; + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Access/Models/NewProjectAccessFile.cs b/Unity.Services.Cli/Unity.Services.Cli.Access/Models/NewProjectAccessFile.cs new file mode 100644 index 0000000..1bda5b8 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Access/Models/NewProjectAccessFile.cs @@ -0,0 +1,75 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Unity.Services.Access.Authoring.Core.Model; +using Unity.Services.Cli.Authoring.Templates; + +namespace Unity.Services.Cli.Access.Models; + +public class NewProjectAccessFile : IFileTemplate +{ + [JsonProperty("$schema")] + public string Value { get; set; } + + [JsonIgnore] + public string Extension => ".ac"; + + [JsonIgnore] + public string FileBodyText => JsonConvert.SerializeObject(this, GetSerializationSettings()); + + static JsonSerializerSettings GetSerializationSettings() + { + var settings = new JsonSerializerSettings() + { + Converters = + { + new StringEnumConverter() + }, + Formatting = Formatting.Indented, + DefaultValueHandling = DefaultValueHandling.IgnoreAndPopulate + }; + return settings; + } + + public NewProjectAccessFile() : this( + name: "project-statements", + path: "project-statements.ac", + statements: GetDefaultStatements(), + value: "https://ugs-config-schemas.unity3d.com/v1/project-access-policy.schema.json") + { + } + + public NewProjectAccessFile(string name, string path, List statements, string value) + { + Path = path; + Name = name; + Statements = statements; + Value = value; + } + + static List GetDefaultStatements() + { + return new List() + { + new AccessControlStatement() + { + Sid = "DenyAccessToAllServices", + Effect = "Deny", + Action = new List + { + "*" + }, + Principal = "Player", + Resource = "urn:ugs:*", + Version = "1.0.0" + } + }; + } + + [JsonIgnore] + public string Name { get; } + + [JsonIgnore] + public string Path { get; set; } + + public List Statements { get; set; } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Access/Service/AccessService.cs b/Unity.Services.Cli/Unity.Services.Cli.Access/Service/AccessService.cs index 7aae91a..57961e0 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.Access/Service/AccessService.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.Access/Service/AccessService.cs @@ -22,7 +22,7 @@ public AccessService(IProjectPolicyApi projectPolicyApi, IPlayerPolicyApi player m_AuthenticationService = authenticationService; } - const string JsonIncorrectFormatExceptionMessage = "Please make sure that the format of your JSON input is correct and all required fields are included. If you need help, please refer to the documentation."; + const string k_JsonIncorrectFormatExceptionMessage = "Please make sure that the format of your JSON input is correct and all required fields are included. If you need help, please refer to the documentation."; static string ReadFile(FileInfo file) { @@ -98,7 +98,7 @@ public async Task UpsertPolicyAsync(string projectId, string environmentId, File } catch { - throw new CliException(JsonIncorrectFormatExceptionMessage, ExitCode.HandledError); + throw new CliException(k_JsonIncorrectFormatExceptionMessage, ExitCode.HandledError); } await m_ProjectPolicyApi.UpsertPolicyAsync(projectId, environmentId, policy, cancellationToken: cancellationToken); } @@ -120,7 +120,7 @@ public async Task UpsertPlayerPolicyAsync(string projectId, string environmentId } catch { - throw new CliException(JsonIncorrectFormatExceptionMessage, ExitCode.HandledError); + throw new CliException(k_JsonIncorrectFormatExceptionMessage, ExitCode.HandledError); } await m_PlayerPolicyApi.UpsertPlayerPolicyAsync(projectId, environmentId, playerId, policy, cancellationToken: cancellationToken); @@ -143,7 +143,7 @@ public async Task DeletePolicyStatementsAsync(string projectId, string environme } catch { - throw new CliException(JsonIncorrectFormatExceptionMessage, ExitCode.HandledError); + throw new CliException(k_JsonIncorrectFormatExceptionMessage, ExitCode.HandledError); } await m_ProjectPolicyApi.DeletePolicyStatementsAsync(projectId, environmentId, deleteOptions, cancellationToken: cancellationToken); @@ -166,9 +166,29 @@ public async Task DeletePlayerPolicyStatementsAsync(string projectId, string env } catch { - throw new CliException(JsonIncorrectFormatExceptionMessage, ExitCode.HandledError); + throw new CliException(k_JsonIncorrectFormatExceptionMessage, ExitCode.HandledError); } await m_PlayerPolicyApi.DeletePlayerPolicyStatementsAsync(projectId, environmentId, playerId, deleteOptions, cancellationToken: cancellationToken); } + + public async Task UpsertProjectAccessCaCAsync( + string projectId, + string environmentId, + Policy policy, + CancellationToken cancellationToken = default) + { + await AuthorizeServiceAsync(cancellationToken); + await m_ProjectPolicyApi.UpsertPolicyAsync(projectId, environmentId, policy, cancellationToken: cancellationToken); + } + + public async Task DeleteProjectAccessCaCAsync( + string projectId, + string environmentId, + DeleteOptions options, + CancellationToken cancellationToken = default) + { + await AuthorizeServiceAsync(cancellationToken); + await m_ProjectPolicyApi.DeletePolicyStatementsAsync(projectId, environmentId, options, cancellationToken: cancellationToken); + } } diff --git a/Unity.Services.Cli/Unity.Services.Cli.Access/Service/IAccessService.cs b/Unity.Services.Cli/Unity.Services.Cli.Access/Service/IAccessService.cs index 881e825..d02941c 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.Access/Service/IAccessService.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.Access/Service/IAccessService.cs @@ -24,4 +24,10 @@ Task DeletePolicyStatementsAsync(string projectId, string environmentId, FileInf Task DeletePlayerPolicyStatementsAsync(string projectId, string environmentId, string playerId, FileInfo file, CancellationToken cancellationToken = default); + + Task UpsertProjectAccessCaCAsync(string projectId, string environmentId, Policy policy, + CancellationToken cancellationToken = default); + + Task DeleteProjectAccessCaCAsync(string projectId, string environmentId, DeleteOptions options, + CancellationToken cancellationToken = default); } diff --git a/Unity.Services.Cli/Unity.Services.Cli.Access/Unity.Services.Cli.Access.csproj b/Unity.Services.Cli/Unity.Services.Cli.Access/Unity.Services.Cli.Access.csproj index aa5c268..528709b 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.Access/Unity.Services.Cli.Access.csproj +++ b/Unity.Services.Cli/Unity.Services.Cli.Access/Unity.Services.Cli.Access.csproj @@ -15,8 +15,10 @@ + + diff --git a/Unity.Services.Cli/Unity.Services.Cli.Authoring.UnitTest/Handlers/DeployHandlerTests.cs b/Unity.Services.Cli/Unity.Services.Cli.Authoring.UnitTest/Handlers/DeployHandlerTests.cs index a932571..6322c92 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.Authoring.UnitTest/Handlers/DeployHandlerTests.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.Authoring.UnitTest/Handlers/DeployHandlerTests.cs @@ -41,10 +41,13 @@ public class TestDeploymentService : IDeploymentService string m_ServiceName = "test"; string m_DeployFileExtension = ".test"; - string IDeploymentService.ServiceType => m_ServiceType; - string IDeploymentService.ServiceName => m_ServiceName; + public string ServiceType => m_ServiceType; + public string ServiceName => m_ServiceName; - string IDeploymentService.DeployFileExtension => m_DeployFileExtension; + public IReadOnlyList FileExtensions => new[] + { + m_DeployFileExtension + }; public Task Deploy(DeployInput deployInput, IReadOnlyList filePaths, string projectId, string environmentId, StatusContext? loadingContext, CancellationToken cancellationToken) @@ -65,10 +68,13 @@ public class TestDeploymentFailureService : IDeploymentService string m_ServiceName = "test"; string m_DeployFileExtension = ".test"; - string IDeploymentService.ServiceType => m_ServiceType; - string IDeploymentService.ServiceName => m_ServiceName; + public string ServiceType => m_ServiceType; + public string ServiceName => m_ServiceName; - string IDeploymentService.DeployFileExtension => m_DeployFileExtension; + public IReadOnlyList FileExtensions => new[] + { + m_DeployFileExtension + }; public Task Deploy(DeployInput deployInput, IReadOnlyList filePaths, string projectId, string environmentId, StatusContext? loadingContext, CancellationToken cancellationToken) @@ -95,10 +101,13 @@ public class TestDeploymentUnhandledExceptionService : IDeploymentService string m_ServiceName = "test"; string m_DeployFileExtension = ".test"; - string IDeploymentService.ServiceType => m_ServiceType; - string IDeploymentService.ServiceName => m_ServiceName; + public string ServiceType => m_ServiceType; + public string ServiceName => m_ServiceName; - string IDeploymentService.DeployFileExtension => m_DeployFileExtension; + public IReadOnlyList FileExtensions => new[] + { + m_DeployFileExtension + }; public Task Deploy(DeployInput deployInput, IReadOnlyList filePaths, string projectId, string environmentId, StatusContext? loadingContext, CancellationToken cancellationToken) @@ -120,8 +129,12 @@ public void SetUp() m_DeploymentService.Setup(s => s.ServiceName) .Returns("mock_test"); - m_DeploymentService.Setup(s => s.DeployFileExtension) - .Returns(".test"); + m_DeploymentService.Setup(s => s.FileExtensions) + .Returns( + new[] + { + ".test" + }); m_DeploymentService.Setup( s => s.Deploy( @@ -168,7 +181,7 @@ public async Task DeployAsync_WithLoadingIndicator_CallsLoadingIndicatorStartLoa { var mockLoadingIndicator = new Mock(); - await DeployHandler.DeployAsync( + await DeployCommandHandler.DeployAsync( It.IsAny(), It.IsAny(), m_UnityEnvironment.Object, @@ -186,7 +199,7 @@ await DeployHandler.DeployAsync( [Test] public async Task DeployAsync_CallsGetServicesCorrectly() { - await DeployHandler.DeployAsync( + await DeployCommandHandler.DeployAsync( m_Host.Object, new DeployInput(), m_UnityEnvironment.Object, m_Logger.Object, @@ -210,7 +223,7 @@ public void DeployAsync_DeploymentFailureThrowsDeploymentFailureException() Assert.ThrowsAsync(async () => { - await DeployHandler.DeployAsync( + await DeployCommandHandler.DeployAsync( m_Host.Object, new DeployInput(), m_UnityEnvironment.Object, m_Logger.Object, @@ -233,7 +246,7 @@ public void DeployAsync_DeploymentFailureThrowsAggregateException() Assert.ThrowsAsync(async () => { - await DeployHandler.DeployAsync( + await DeployCommandHandler.DeployAsync( m_Host.Object, new DeployInput(), m_UnityEnvironment.Object, m_Logger.Object, @@ -253,7 +266,7 @@ public async Task DeployAsync_ReconcileWillNotExecutedWithNoServiceFlag() Reconcile = true }; - await DeployHandler.DeployAsync( + await DeployCommandHandler.DeployAsync( m_Host.Object, input, m_UnityEnvironment.Object, @@ -286,7 +299,7 @@ public async Task DeployAsync_ReconcileExecuteWithServiceFlag() } }; - await DeployHandler.DeployAsync( + await DeployCommandHandler.DeployAsync( m_Host.Object, input, m_UnityEnvironment.Object, @@ -318,7 +331,7 @@ public async Task DeployAsync_ExecuteWithCorrectServiceFlag() } }; - await DeployHandler.DeployAsync( + await DeployCommandHandler.DeployAsync( m_Host.Object, input, m_UnityEnvironment.Object, @@ -350,7 +363,7 @@ public async Task DeployAsync_NotExecuteWithIncorrectServiceFlag() } }; - await DeployHandler.DeployAsync( + await DeployCommandHandler.DeployAsync( m_Host.Object, input, m_UnityEnvironment.Object, @@ -379,7 +392,7 @@ public async Task DeployAsync_TableOutputWithJsonFlag() IsJson = true }; - await DeployHandler.DeployAsync( + await DeployCommandHandler.DeployAsync( m_Host.Object, input, m_UnityEnvironment.Object, @@ -423,7 +436,7 @@ public async Task DeployAsync_MultipleDeploymentDefinitionsException_NotExecuted new Mock().Object, "path")); - await DeployHandler.DeployAsync( + await DeployCommandHandler.DeployAsync( m_Host.Object, input, m_UnityEnvironment.Object, @@ -465,7 +478,7 @@ public async Task DeployAsync_DeploymentDefinitionIntersectionException_NotExecu new Dictionary>(), true)); - await DeployHandler.DeployAsync( + await DeployCommandHandler.DeployAsync( m_Host.Object, input, m_UnityEnvironment.Object, @@ -519,7 +532,7 @@ public async Task DeployAsync_DeploymentDefinitionsHaveExclusion_ExclusionsLogge It.IsAny>())) .Returns(mockResult.Object); - await DeployHandler.DeployAsync( + await DeployCommandHandler.DeployAsync( m_Host.Object, input, m_UnityEnvironment.Object, diff --git a/Unity.Services.Cli/Unity.Services.Cli.Authoring.UnitTest/Handlers/FetchHandlerTests.cs b/Unity.Services.Cli/Unity.Services.Cli.Authoring.UnitTest/Handlers/FetchHandlerTests.cs index df3ffcb..d787478 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.Authoring.UnitTest/Handlers/FetchHandlerTests.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.Authoring.UnitTest/Handlers/FetchHandlerTests.cs @@ -14,6 +14,7 @@ using Unity.Services.Cli.Authoring.Model; using Unity.Services.Cli.Authoring.Service; using Unity.Services.Cli.Common.Telemetry.AnalyticEvent; +using Unity.Services.Cli.Common.Utils; using Unity.Services.Cli.TestUtils; using Unity.Services.Deployment.Core.Model; using Unity.Services.DeploymentApi.Editor; @@ -23,12 +24,14 @@ namespace Unity.Services.Cli.Authoring.UnitTest.Handlers; [TestFixture] public class FetchHandlerTests { + const string k_ValidEnvironmentId = "00000000-0000-0000-0000-000000000000"; readonly Mock m_Host = new(); readonly Mock m_Logger = new(); readonly Mock m_ServiceProvider = new(); readonly Mock m_FetchService = new(); readonly Mock m_DdefService = new(); readonly Mock m_AnalyticsEventBuilder = new(); + readonly Mock m_MockEnvironment = new(); [SetUp] public void SetUp() @@ -38,16 +41,24 @@ public void SetUp() m_FetchService.Reset(); m_Logger.Reset(); m_AnalyticsEventBuilder.Reset(); + m_MockEnvironment.Reset(); + m_MockEnvironment.Setup(x => x.FetchIdentifierAsync(CancellationToken.None)).Returns(Task.FromResult(k_ValidEnvironmentId)); m_FetchService.Setup(s => s.ServiceName) .Returns("mock_test"); - m_FetchService.Setup(s => s.FileExtension) - .Returns(".test"); + m_FetchService.Setup(s => s.FileExtensions) + .Returns( + new[] + { + ".test" + }); m_FetchService.Setup( s => s.FetchAsync( It.IsAny(), - It.IsAny>(), + It.IsAny>(), + It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny())) .Returns( @@ -88,13 +99,18 @@ class TestFetchService : IFetchService string m_ServiceName = "test"; string m_DeployFileExtension = ".test"; - string IFetchService.ServiceType => m_ServiceType; - string IFetchService.ServiceName => m_ServiceName; - string IFetchService.FileExtension => m_DeployFileExtension; + public string ServiceType => m_ServiceType; + public string ServiceName => m_ServiceName; + public IReadOnlyList FileExtensions => new[] + { + m_DeployFileExtension + }; public Task FetchAsync( FetchInput input, IReadOnlyList filePaths, + string projectId, + string environmentId, StatusContext? loadingContext, CancellationToken cancellationToken) { @@ -113,9 +129,10 @@ public async Task FetchAsync_WithLoadingIndicator_CallsLoadingIndicatorStartLoad { var mockLoadingIndicator = new Mock(); - await FetchHandler.FetchAsync( + await FetchCommandHandler.FetchAsync( null!, null!, + m_MockEnvironment.Object, null!, m_DdefService.Object, mockLoadingIndicator.Object, @@ -135,9 +152,10 @@ public async Task FetchAsync_PrintsCorrectDryRun(bool dryRun) var fetchInput = new FetchInput { DryRun = dryRun }; var mockDdefService = new Mock(); - await FetchHandler.FetchAsync( + await FetchCommandHandler.FetchAsync( m_Host.Object, fetchInput, + m_MockEnvironment.Object, mockLogger.Object, (StatusContext?)null, m_DdefService.Object, @@ -159,9 +177,10 @@ public async Task FetchAsync_CallsGetServicesCorrectly() { var fetchInput = new FetchInput(); - await FetchHandler.FetchAsync( + await FetchCommandHandler.FetchAsync( m_Host.Object, fetchInput, + m_MockEnvironment.Object, m_Logger.Object, (StatusContext)null!, m_DdefService.Object, @@ -188,9 +207,10 @@ public void FetchAsync_ThrowsAggregateException() var fetchInput = new FetchInput(); Assert.ThrowsAsync(async () => { - await FetchHandler.FetchAsync( + await FetchCommandHandler.FetchAsync( m_Host.Object, fetchInput, + m_MockEnvironment.Object, m_Logger.Object, (StatusContext)null!, m_DdefService.Object, @@ -207,9 +227,10 @@ public async Task FetchAsync_ReconcileWillNotExecutedWithNoServiceFlag() Reconcile = true }; - await FetchHandler.FetchAsync( + await FetchCommandHandler.FetchAsync( m_Host.Object, input, + m_MockEnvironment.Object, m_Logger.Object, (StatusContext)null!, m_DdefService.Object, @@ -220,6 +241,8 @@ await FetchHandler.FetchAsync( s => s.FetchAsync( It.IsAny(), It.IsAny>(), + It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); @@ -237,9 +260,10 @@ public async Task FetchAsync_ReconcileExecuteWithServiceFlag() } }; - await FetchHandler.FetchAsync( + await FetchCommandHandler.FetchAsync( m_Host.Object, input, + m_MockEnvironment.Object, m_Logger.Object, (StatusContext)null!, m_DdefService.Object, @@ -249,7 +273,9 @@ await FetchHandler.FetchAsync( m_FetchService.Verify( s => s.FetchAsync( It.IsAny(), - It.IsAny>(), + It.IsAny>(), + It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); @@ -266,9 +292,10 @@ public async Task FetchAsync_ExecuteWithCorrectServiceFlag() } }; - await FetchHandler.FetchAsync( + await FetchCommandHandler.FetchAsync( m_Host.Object, input, + m_MockEnvironment.Object, m_Logger.Object, (StatusContext)null!, m_DdefService.Object, @@ -278,7 +305,9 @@ await FetchHandler.FetchAsync( m_FetchService.Verify( s => s.FetchAsync( It.IsAny(), - It.IsAny>(), + It.IsAny>(), + It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); @@ -295,9 +324,10 @@ public async Task FetchAsync_NotExecuteWithIncorrectServiceFlag() } }; - await FetchHandler.FetchAsync( + await FetchCommandHandler.FetchAsync( m_Host.Object, input, + m_MockEnvironment.Object, m_Logger.Object, (StatusContext)null!, m_DdefService.Object, @@ -308,6 +338,8 @@ await FetchHandler.FetchAsync( s => s.FetchAsync( It.IsAny(), It.IsAny>(), + It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); @@ -336,9 +368,10 @@ public async Task FetchAsync_MultipleDeploymentDefinitionsException_NotExecuted( new Mock().Object, "path")); - await FetchHandler.FetchAsync( + await FetchCommandHandler.FetchAsync( m_Host.Object, input, + m_MockEnvironment.Object, m_Logger.Object, (StatusContext)null!, m_DdefService.Object, @@ -349,6 +382,8 @@ await FetchHandler.FetchAsync( s => s.FetchAsync( It.IsAny(), It.IsAny>(), + It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); @@ -375,9 +410,10 @@ public async Task FetchAsync_DeploymentDefinitionIntersectionException_NotExecut new Dictionary>(), true)); - await FetchHandler.FetchAsync( + await FetchCommandHandler.FetchAsync( m_Host.Object, input, + m_MockEnvironment.Object, m_Logger.Object, (StatusContext)null!, m_DdefService.Object, @@ -388,6 +424,8 @@ await FetchHandler.FetchAsync( s => s.FetchAsync( It.IsAny(), It.IsAny>(), + It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); @@ -427,9 +465,10 @@ public async Task FetchAsync_DeploymentDefinitionsHaveExclusion_ExclusionsLogged .Returns(mockResult.Object); - await FetchHandler.FetchAsync( + await FetchCommandHandler.FetchAsync( m_Host.Object, input, + m_MockEnvironment.Object, m_Logger.Object, (StatusContext)null!, m_DdefService.Object, @@ -452,9 +491,10 @@ public async Task FetchAsync_ReconcileWithDdef_NotExecuted() Reconcile = true }; - await FetchHandler.FetchAsync( + await FetchCommandHandler.FetchAsync( m_Host.Object, input, + m_MockEnvironment.Object, m_Logger.Object, (StatusContext)null!, m_DdefService.Object, @@ -465,6 +505,8 @@ await FetchHandler.FetchAsync( s => s.FetchAsync( It.IsAny(), It.IsAny>(), + It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); @@ -476,14 +518,18 @@ class TestFetchUnhandledExceptionFetchService : IFetchService string m_ServiceName = "test"; string m_DeployFileExtension = ".test"; - string IFetchService.ServiceType => m_ServiceType; - string IFetchService.ServiceName => m_ServiceName; - - string IFetchService.FileExtension => m_DeployFileExtension; + public string ServiceType => m_ServiceType; + public string ServiceName => m_ServiceName; + public IReadOnlyList FileExtensions => new[] + { + m_DeployFileExtension + }; public Task FetchAsync( FetchInput input, IReadOnlyList filePaths, + string projectId, + string environmentId, StatusContext? loadingContext, CancellationToken cancellationToken) { diff --git a/Unity.Services.Cli/Unity.Services.Cli.Authoring/DeployModule.cs b/Unity.Services.Cli/Unity.Services.Cli.Authoring/DeployModule.cs index ece0f34..cc9d6ab 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.Authoring/DeployModule.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.Authoring/DeployModule.cs @@ -45,7 +45,7 @@ public DeployModule() ICliDeploymentDefinitionService, IAnalyticsEventBuilder, CancellationToken>( - DeployHandler.DeployAsync); + DeployCommandHandler.DeployAsync); } /// diff --git a/Unity.Services.Cli/Unity.Services.Cli.Authoring/FetchModule.cs b/Unity.Services.Cli/Unity.Services.Cli.Authoring/FetchModule.cs index b29d793..a490149 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.Authoring/FetchModule.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.Authoring/FetchModule.cs @@ -8,6 +8,7 @@ using Unity.Services.Cli.Common.Input; using Unity.Services.Cli.Authoring.Service; using Unity.Services.Cli.Common.Telemetry.AnalyticEvent; +using Unity.Services.Cli.Common.Utils; namespace Unity.Services.Cli.Authoring; @@ -34,11 +35,12 @@ public FetchModule() ModuleRootCommand.SetHandler< IHost, FetchInput, + IUnityEnvironment, ILogger, ICliDeploymentDefinitionService, ILoadingIndicator, IAnalyticsEventBuilder, CancellationToken>( - FetchHandler.FetchAsync); + FetchCommandHandler.FetchAsync); } } diff --git a/Unity.Services.Cli/Unity.Services.Cli.Authoring/Handlers/FetchHandler.cs b/Unity.Services.Cli/Unity.Services.Cli.Authoring/Handlers/AuthoringHandlerCommon.cs similarity index 51% rename from Unity.Services.Cli/Unity.Services.Cli.Authoring/Handlers/FetchHandler.cs rename to Unity.Services.Cli/Unity.Services.Cli.Authoring/Handlers/AuthoringHandlerCommon.cs index 84a6b98..af64c8f 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.Authoring/Handlers/FetchHandler.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.Authoring/Handlers/AuthoringHandlerCommon.cs @@ -1,66 +1,42 @@ -using System.Text; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -using Spectre.Console; using Unity.Services.Cli.Authoring.DeploymentDefinition; using Unity.Services.Cli.Authoring.Input; using Unity.Services.Cli.Authoring.Model; using Unity.Services.Cli.Authoring.Model.TableOutput; using Unity.Services.Cli.Authoring.Service; -using Unity.Services.Cli.Common.Console; using Unity.Services.Cli.Common.Exceptions; using Unity.Services.Cli.Common.Logging; using Unity.Services.Cli.Common.Telemetry.AnalyticEvent; namespace Unity.Services.Cli.Authoring.Handlers; -static class FetchHandler +static class AuthoringHandlerCommon { - public static async Task FetchAsync( - IHost host, - FetchInput input, + public static bool PreActionValidation( + AuthoringInput input, ILogger logger, - ICliDeploymentDefinitionService deploymentDefinitionService, - ILoadingIndicator loadingIndicator, - IAnalyticsEventBuilder analyticsEventBuilder, - CancellationToken cancellationToken) - { - await loadingIndicator.StartLoadingAsync( - $"Fetching files...", - context => FetchAsync( - host, - input, - logger, - context, - deploymentDefinitionService, - analyticsEventBuilder, - cancellationToken)); - } - - internal static async Task FetchAsync( - IHost host, - FetchInput input, - ILogger logger, - StatusContext? loadingContext, - ICliDeploymentDefinitionService definitionService, - IAnalyticsEventBuilder analyticsEventBuilder, - CancellationToken cancellationToken) + IReadOnlyList services, + IReadOnlyList inputPaths) + where T : IAuthoringService { - var services = host.Services.GetServices().ToList(); - var supportedServicesStr = string.Join(", ", services.Select(s => s.ServiceType)); - var supportedServiceNamesStr = string.Join(", ", services.Select(s => s.ServiceName)); + var serviceNames = services.Select(s => s.ServiceName).ToList(); + var supportedServiceNamesStr = string.Join(", ", serviceNames); + + bool areAllServicesSupported = !AreAllServicesSupported( + input, + serviceNames, + out var unsupportedServicesStr); - bool areAllServicesSupported = !AreAllServicesSupported(input, services, out var unsupportedServicesStr); - if (string.IsNullOrEmpty(input.Path) || areAllServicesSupported) + if (inputPaths.Count == 0 || areAllServicesSupported) { if (areAllServicesSupported) { logger.LogError($"These service options were not recognized: {unsupportedServicesStr}."); } - logger.LogInformation($"Currently supported services are: {supportedServicesStr}." + + logger.LogInformation( + $"Currently supported services are: {supportedServicesStr}." + $"{Environment.NewLine} You can filter your service(s) with the --services option: " + $"{supportedServiceNamesStr}"); } @@ -72,65 +48,80 @@ internal static async Task FetchAsync( logger.LogError( "Reconcile is a destructive operation. Specify your service(s) with the --services option: {SupportedServiceNamesStr}", supportedServiceNamesStr); - return; + return false; } - if (Path.GetExtension(input.Path) == CliDeploymentDefinitionService.Extension) + if (typeof(T) == typeof(IFetchService) + && inputPaths.Count > 0 + && Path.GetExtension(inputPaths[0]) == CliDeploymentDefinitionService.Extension) { - logger.LogError("Reconcile is not compatible with Deployment Definitions"); - return; + logger.LogError("Reconcile Fetch is not compatible with Deployment Definitions"); + return false; } } - var fetchResult = Array.Empty(); - - var fetchServices = services - .Where(s => CheckService(input, s)) - .ToList(); - - var inputPaths = new List { input.Path }; - + return true; + } - var ddefResult = GetDdefResult( - definitionService, - logger, - inputPaths, - fetchServices.Select(ds => ds.FileExtension)); + public static void SendAnalytics( + IAnalyticsEventBuilder analyticsEventBuilder, + IReadOnlyList inputPaths, + T[] deploymentServices) where T : IAuthoringService + { + analyticsEventBuilder.SetAuthoringCommandlinePathsInputCount(inputPaths); - if (ddefResult == null) + foreach (var deploymentService in deploymentServices) { - return; + analyticsEventBuilder.AddAuthoringServiceProcessed(deploymentService.ServiceName); } + } - analyticsEventBuilder.SetAuthoringCommandlinePathsInputCount(new[] { input.Path }); - - foreach (var fetchService in fetchServices) + public static IDeploymentDefinitionFilteringResult? GetDdefResult( + ICliDeploymentDefinitionService ddefService, + ILogger logger, + IEnumerable inputPaths, + IEnumerable extensions) + { + IDeploymentDefinitionFilteringResult? ddefResult = null; + try { - analyticsEventBuilder.AddAuthoringServiceProcessed(fetchService.ServiceName); + ddefResult = ddefService + .GetFilesFromInput(inputPaths, extensions); } - - var tasks = fetchServices - .Select( - m => m.FetchAsync( - input, - ddefResult.AllFilesByExtension[m.FileExtension], - loadingContext, - cancellationToken)) - .ToArray(); - - try + catch (MultipleDeploymentDefinitionInDirectoryException e) { - fetchResult = await Task.WhenAll(tasks); + logger.LogError(e.Message); } - catch + catch (DeploymentDefinitionFileIntersectionException e) { - // do nothing - // this allows us to capture all the exceptions - // and handle them below + logger.LogError(e.Message); } - var totalResult = new FetchResult(fetchResult, input.DryRun); + return ddefResult; + } + + public static bool CheckService(AuthoringInput input, string serviceName) + { + if (!input.Reconcile && input.Services.Count == 0) + return true; + + return input.Services.Contains(serviceName); + } + public static bool AreAllServicesSupported(AuthoringInput input, IReadOnlyList serviceNames, out string unsupportedServices) + { + unsupportedServices = string.Join(", ", input.Services.Except(serviceNames)); + + return string.IsNullOrEmpty(unsupportedServices); + } + + public static void PrintResult( + AuthoringInput input, + ILogger logger, + Task[] tasks, + T totalResult, + IDeploymentDefinitionFilteringResult ddefResult) where T : AuthorResult + { if (input.IsJson) { var tableResult = new TableContent() @@ -158,7 +149,7 @@ internal static async Task FetchAsync( // Get Exceptions from faulted deployments var exceptions = tasks .Where(t => t.IsFaulted) - .Select(t => t.Exception!.InnerException) + .Select(t => t.Exception?.InnerException) .ToList(); if (exceptions.Any()) @@ -171,45 +162,4 @@ internal static async Task FetchAsync( throw new DeploymentFailureException(); } } - - static bool CheckService(FetchInput input, IFetchService service) - { - if (!input.Reconcile && input.Services.Count == 0) - return true; - - return input.Services.Contains(service.ServiceName); - } - - static bool AreAllServicesSupported(FetchInput input, IReadOnlyList services, out string unsupportedServices) - { - var serviceNames = services.Select(s => s.ServiceName); - - unsupportedServices = string.Join(", ", input.Services.Except(serviceNames)); - - return string.IsNullOrEmpty(unsupportedServices); - } - - static IDeploymentDefinitionFilteringResult? GetDdefResult( - ICliDeploymentDefinitionService ddefService, - ILogger logger, - IEnumerable inputPaths, - IEnumerable extensions) - { - IDeploymentDefinitionFilteringResult? ddefResult = null; - try - { - ddefResult = ddefService - .GetFilesFromInput(inputPaths, extensions); - } - catch (MultipleDeploymentDefinitionInDirectoryException e) - { - logger.LogError(e.Message); - } - catch (DeploymentDefinitionFileIntersectionException e) - { - logger.LogError(e.Message); - } - - return ddefResult; - } } diff --git a/Unity.Services.Cli/Unity.Services.Cli.Authoring/Handlers/DeployCommandHandler.cs b/Unity.Services.Cli/Unity.Services.Cli.Authoring/Handlers/DeployCommandHandler.cs new file mode 100644 index 0000000..71e76f9 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Authoring/Handlers/DeployCommandHandler.cs @@ -0,0 +1,122 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Spectre.Console; +using Unity.Services.Cli.Authoring.Input; +using Unity.Services.Cli.Authoring.Model; +using Unity.Services.Cli.Authoring.Service; +using Unity.Services.Cli.Common.Console; +using Unity.Services.Cli.Common.Telemetry.AnalyticEvent; +using Unity.Services.Cli.Common.Utils; + +namespace Unity.Services.Cli.Authoring.Handlers; + +static class DeployCommandHandler +{ + public static async Task DeployAsync( + IHost host, + DeployInput input, + IUnityEnvironment unityEnvironment, + ILogger logger, + ILoadingIndicator loadingIndicator, + ICliDeploymentDefinitionService definitionService, + IAnalyticsEventBuilder analyticsEventBuilder, + CancellationToken cancellationToken + ) + { + await loadingIndicator.StartLoadingAsync( + $"Deploying files...", + context => DeployAsync( + host, + input, + unityEnvironment, + logger, + context, + definitionService, + analyticsEventBuilder, + cancellationToken)); + } + + internal static async Task DeployAsync( + IHost host, + DeployInput input, + IUnityEnvironment unityEnvironment, + ILogger logger, + StatusContext? loadingContext, + ICliDeploymentDefinitionService definitionService, + IAnalyticsEventBuilder analyticsEventBuilder, + CancellationToken cancellationToken) + { + IReadOnlyList inputPaths = input.Paths; + var services = host.Services.GetServices().ToList(); + + if (!AuthoringHandlerCommon.PreActionValidation(input, logger, services, inputPaths)) + { + return; + } + + var deploymentServices = services + .Where(s => AuthoringHandlerCommon.CheckService(input, s.ServiceName)) + .ToArray(); + + var ddefResult = AuthoringHandlerCommon.GetDdefResult( + definitionService, + logger, + input.Paths, + deploymentServices.SelectMany(ds => ds.FileExtensions)); + + if (ddefResult == null) + { + return; + } + + AuthoringHandlerCommon.SendAnalytics(analyticsEventBuilder, inputPaths, deploymentServices); + + var projectId = input.CloudProjectId!; + var environmentId = await unityEnvironment.FetchIdentifierAsync(cancellationToken); + + var tasks = deploymentServices + .Select( + service => + { + var filePaths = service.FileExtensions + .SelectMany(extension => ddefResult.AllFilesByExtension[extension]) + .ToArray(); + + return service.Deploy( + input, + filePaths, + projectId, + environmentId, + loadingContext, + cancellationToken); + }) + .ToArray(); + + try + { + await Task.WhenAll(tasks); + } + catch + { + // do nothing + // this allows us to capture all the exceptions + // and handle them below + } + + // Get Results from successfully ran deployments + var deploymentResults = tasks + .Where(t => t.IsCompletedSuccessfully) + .Select(t => t.Result) + .ToArray(); + + var totalResult = new DeploymentResult(deploymentResults, input.DryRun); + + AuthoringHandlerCommon.PrintResult( + input, + logger, + tasks, + totalResult, + ddefResult); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Authoring/Handlers/DeployHandler.cs b/Unity.Services.Cli/Unity.Services.Cli.Authoring/Handlers/DeployHandler.cs deleted file mode 100644 index d79b418..0000000 --- a/Unity.Services.Cli/Unity.Services.Cli.Authoring/Handlers/DeployHandler.cs +++ /dev/null @@ -1,221 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Spectre.Console; -using Unity.Services.Cli.Authoring.DeploymentDefinition; -using Unity.Services.Cli.Authoring.Input; -using Unity.Services.Cli.Authoring.Model; -using Unity.Services.Cli.Authoring.Model.TableOutput; -using Unity.Services.Cli.Authoring.Service; -using Unity.Services.Cli.Common.Console; -using Unity.Services.Cli.Common.Exceptions; -using Unity.Services.Cli.Common.Logging; -using Unity.Services.Cli.Common.Telemetry.AnalyticEvent; -using Unity.Services.Cli.Common.Utils; - -namespace Unity.Services.Cli.Authoring.Handlers; - -static class DeployHandler -{ - public static async Task DeployAsync( - IHost host, - DeployInput input, - IUnityEnvironment unityEnvironment, - ILogger logger, - ILoadingIndicator loadingIndicator, - ICliDeploymentDefinitionService definitionService, - IAnalyticsEventBuilder analyticsEventBuilder, - CancellationToken cancellationToken - ) - { - await loadingIndicator.StartLoadingAsync( - $"Deploying files...", - context => DeployAsync( - host, - input, - unityEnvironment, - logger, - context, - definitionService, - analyticsEventBuilder, - cancellationToken)); - } - - internal static async Task DeployAsync( - IHost host, - DeployInput input, - IUnityEnvironment unityEnvironment, - ILogger logger, - StatusContext? loadingContext, - ICliDeploymentDefinitionService definitionService, - IAnalyticsEventBuilder analyticsEventBuilder, - CancellationToken cancellationToken - ) - { - var services = host.Services.GetServices().ToList(); - - var supportedServicesStr = string.Join(", ", services.Select(s => s.ServiceType)); - var supportedServiceNamesStr = string.Join(", ", services.Select(s => s.ServiceName)); - - bool areAllServicesSupported = !AreAllServicesSupported(input, services, out var unsupportedServicesStr); - if (input.Paths.Count == 0 || areAllServicesSupported) - { - if (areAllServicesSupported) - { - logger.LogError($"These service options were not recognized: {unsupportedServicesStr}."); - } - - logger.LogInformation($"Currently supported services are: {supportedServicesStr}." + - $"{Environment.NewLine} You can filter your service(s) with the --services option: " + - $"{supportedServiceNamesStr}"); - } - - if (input.Reconcile && input.Services.Count == 0) - { - logger.LogError( - "Reconcile is a destructive operation. Specify your service(s) with the --services option: {SupportedServiceNamesStr}", - supportedServiceNamesStr); - return; - } - - var environmentId = await unityEnvironment.FetchIdentifierAsync(cancellationToken); - var projectId = input.CloudProjectId!; - var deploymentServices = services - .Where(s => CheckService(input, s)) - .ToArray(); - - var ddefResult = GetDdefResult( - definitionService, - logger, - input.Paths, - deploymentServices.Select(ds => ds.DeployFileExtension)); - if (ddefResult == null) - { - return; - } - - analyticsEventBuilder.SetAuthoringCommandlinePathsInputCount(input.Paths); - - foreach (var deploymentService in deploymentServices) - { - analyticsEventBuilder.AddAuthoringServiceProcessed(deploymentService.ServiceName); - } - - var tasks = deploymentServices.Select( - m => m.Deploy( - input, - ddefResult.AllFilesByExtension[m.DeployFileExtension], - projectId, - environmentId, - loadingContext, - cancellationToken)) - .ToArray(); - - try - { - await Task.WhenAll(tasks); - } - catch - { - // do nothing - // this allows us to capture all the exceptions - // and handle them below - } - - // Get Results from successfully ran deployments - var deploymentResults = tasks - .Where(t => t.IsCompletedSuccessfully) - .Select(t => t.Result) - .ToArray(); - - var totalResult = new DeploymentResult( - deploymentResults.SelectMany(x => x.Updated).ToList(), - deploymentResults.SelectMany(x => x.Deleted).ToList(), - deploymentResults.SelectMany(x => x.Created).ToList(), - deploymentResults.SelectMany(x => x.Deployed).ToList(), - deploymentResults.SelectMany(x => x.Failed).ToList(), - input.DryRun - ); - - if (input.IsJson) - { - var tableResult = new TableContent() - { - IsDryRun = input.DryRun - }; - - foreach (var task in tasks) - { - tableResult.AddRows(task.Result.ToTable()); - } - - logger.LogResultValue(tableResult); - } - else - { - logger.LogResultValue(totalResult); - } - - if (ddefResult.DefinitionFiles.HasExcludes) - { - logger.LogInformation(ddefResult.GetExclusionsLogMessage()); - } - - // Get Exceptions from faulted deployments - var exceptions = tasks - .Where(t => t.IsFaulted) - .Select(t => t.Exception?.InnerException) - .ToList(); - - if (exceptions.Any()) - { - throw new AggregateException(exceptions!); - } - - if (totalResult.Failed.Any()) - { - throw new DeploymentFailureException(); - } - } - - static bool CheckService(DeployInput input, IDeploymentService service) - { - if (!input.Reconcile && input.Services.Count == 0) - return true; - - return input.Services.Contains(service.ServiceName); - } - - static bool AreAllServicesSupported(DeployInput input, IReadOnlyList services, out string unsupportedServices) - { - var serviceNames = services.Select(s => s.ServiceName); - - unsupportedServices = string.Join(", ", input.Services.Except(serviceNames)); - - return string.IsNullOrEmpty(unsupportedServices); - } - - static IDeploymentDefinitionFilteringResult? GetDdefResult( - ICliDeploymentDefinitionService ddefService, - ILogger logger, - IEnumerable inputPaths, - IEnumerable extensions) - { - IDeploymentDefinitionFilteringResult? ddefResult = null; - try - { - ddefResult = ddefService - .GetFilesFromInput(inputPaths, extensions); - } - catch (MultipleDeploymentDefinitionInDirectoryException e) - { - logger.LogError(e.Message); - } - catch (DeploymentDefinitionFileIntersectionException e) - { - logger.LogError(e.Message); - } - - return ddefResult; - } -} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Authoring/Handlers/FetchCommandHandler.cs b/Unity.Services.Cli/Unity.Services.Cli.Authoring/Handlers/FetchCommandHandler.cs new file mode 100644 index 0000000..b13310d --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Authoring/Handlers/FetchCommandHandler.cs @@ -0,0 +1,122 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Spectre.Console; +using Unity.Services.Cli.Authoring.Input; +using Unity.Services.Cli.Authoring.Model; +using Unity.Services.Cli.Authoring.Service; +using Unity.Services.Cli.Common.Console; +using Unity.Services.Cli.Common.Telemetry.AnalyticEvent; +using Unity.Services.Cli.Common.Utils; + +namespace Unity.Services.Cli.Authoring.Handlers; + +static class FetchCommandHandler +{ + public static async Task FetchAsync( + IHost host, + FetchInput input, + IUnityEnvironment unityEnvironment, + ILogger logger, + ICliDeploymentDefinitionService deploymentDefinitionService, + ILoadingIndicator loadingIndicator, + IAnalyticsEventBuilder analyticsEventBuilder, + CancellationToken cancellationToken) + { + await loadingIndicator.StartLoadingAsync( + $"Fetching files...", + context => FetchAsync( + host, + input, + unityEnvironment, + logger, + context, + deploymentDefinitionService, + analyticsEventBuilder, + cancellationToken)); + } + + internal static async Task FetchAsync( + IHost host, + FetchInput input, + IUnityEnvironment unityEnvironment, + ILogger logger, + StatusContext? loadingContext, + ICliDeploymentDefinitionService definitionService, + IAnalyticsEventBuilder analyticsEventBuilder, + CancellationToken cancellationToken) + { + var inputPaths = new List(); + if (!string.IsNullOrEmpty(input.Path)) + { + inputPaths.Add(input.Path); + } + + var services = host.Services.GetServices().ToList(); + + if (!AuthoringHandlerCommon.PreActionValidation(input, logger, services, inputPaths)) + { + return; + } + + var fetchResult = Array.Empty(); + + var fetchServices = services + .Where(s => AuthoringHandlerCommon.CheckService(input, s.ServiceName)) + .ToArray(); + + var ddefResult = AuthoringHandlerCommon.GetDdefResult( + definitionService, + logger, + inputPaths, + fetchServices.SelectMany(ds => ds.FileExtensions)); + + if (ddefResult == null) + { + return; + } + + AuthoringHandlerCommon.SendAnalytics(analyticsEventBuilder, inputPaths, fetchServices); + + var projectId = input.CloudProjectId!; + var environmentId = await unityEnvironment.FetchIdentifierAsync(cancellationToken); + + var tasks = fetchServices + .Select( + service => + { + var filePaths = service.FileExtensions + .SelectMany(extension => ddefResult.AllFilesByExtension[extension]) + .ToArray(); + + return service.FetchAsync( + input, + filePaths, + projectId, + environmentId, + loadingContext, + cancellationToken); + }) + .ToArray(); + + try + { + fetchResult = await Task.WhenAll(tasks); + } + catch + { + // do nothing + // this allows us to capture all the exceptions + // and handle them below + } + + var totalResult = new FetchResult(fetchResult, input.DryRun); + + AuthoringHandlerCommon.PrintResult( + input, + logger, + tasks, + totalResult, + ddefResult); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Authoring/Handlers/NewFileHandler.cs b/Unity.Services.Cli/Unity.Services.Cli.Authoring/Handlers/NewFileHandler.cs index e8722f3..71ee4ff 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.Authoring/Handlers/NewFileHandler.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.Authoring/Handlers/NewFileHandler.cs @@ -10,8 +10,8 @@ namespace Unity.Services.Cli.Authoring.Handlers; public static class NewFileHandler { - public static Command AddNewFileCommand(this Command? self, string serviceName) - where T : IFileTemplate + public static Command AddNewFileCommand(this Command? self, string serviceName, string defaultFileName = "new_file") + where T : IFileTemplate, new() { Command newFileCommand = new("new-file", $"Create new {serviceName} config file.") { @@ -20,7 +20,7 @@ public static Command AddNewFileCommand(this Command? self, string serviceNam }; newFileCommand.SetHandler - ((input, file, logger, token) => NewFileAsync(input, file, Activator.CreateInstance(), logger, token)); + ((input, file, logger, token) => NewFileAsync(input, file, new T(), logger, token, defaultFileName)); return newFileCommand; } @@ -30,10 +30,11 @@ public static async Task NewFileAsync( IFile file, T template, ILogger logger, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + string defaultFileName = "new_file") where T : IFileTemplate { - input.File = Path.ChangeExtension(input.File!, template.Extension); + input.File = Path.ChangeExtension(input.File ?? defaultFileName, template.Extension); if (file.Exists(input.File) && !input.UseForce) { diff --git a/Unity.Services.Cli/Unity.Services.Cli.Authoring/Input/AuthoringInput.cs b/Unity.Services.Cli/Unity.Services.Cli.Authoring/Input/AuthoringInput.cs new file mode 100644 index 0000000..2506fd1 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Authoring/Input/AuthoringInput.cs @@ -0,0 +1,38 @@ +using System.CommandLine; +using Unity.Services.Cli.Common.Input; + +namespace Unity.Services.Cli.Authoring.Input; + +public class AuthoringInput : CommonInput +{ + public static readonly Option DryRunOption = new( + new[] + { + "--dry-run" + }, + "Perform a trial run with no changes made."); + + [InputBinding(nameof(DryRunOption))] + public bool DryRun { get; set; } = false; + + public static readonly Option ReconcileOption = new( + new[] + { + "--reconcile" + }, + "Delete content not part of deploy."); + + [InputBinding(nameof(ReconcileOption))] + public bool Reconcile { get; set; } = false; + + public static readonly Option> ServiceOptions = new( + new[] + { + "--services", + "-s" + }, + "The name(s) of the service(s) to perform the command on."); + + [InputBinding(nameof(ServiceOptions))] + public IReadOnlyList Services { get; set; } = new List(); +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Authoring/Input/DeployInput.cs b/Unity.Services.Cli/Unity.Services.Cli.Authoring/Input/DeployInput.cs index 0e9aaa6..32c06e9 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.Authoring/Input/DeployInput.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.Authoring/Input/DeployInput.cs @@ -6,7 +6,7 @@ namespace Unity.Services.Cli.Authoring.Input; /// /// Deploy command input /// -public class DeployInput : CommonInput +public class DeployInput : AuthoringInput { public static readonly Argument> PathsArgument = new( "paths", @@ -14,35 +14,4 @@ public class DeployInput : CommonInput [InputBinding(nameof(PathsArgument))] public IReadOnlyList Paths { get; set; } = new List(); - - public static readonly Option DryRunOption = new( - new[] - { - "--dry-run" - }, - "Perform a trial run with no changes made."); - - [InputBinding(nameof(DryRunOption))] - public bool DryRun { get; set; } = false; - - public static readonly Option ReconcileOption = new( - new[] - { - "--reconcile" - }, - "Delete content not part of deploy."); - - [InputBinding(nameof(ReconcileOption))] - public bool Reconcile { get; set; } = false; - - public static readonly Option> ServiceOptions = new( - new[] - { - "--services", - "-s" - }, - "The name(s) of the service(s) to perform the command on."); - - [InputBinding(nameof(ServiceOptions))] - public IReadOnlyList Services { get; set; } = new List(); } diff --git a/Unity.Services.Cli/Unity.Services.Cli.Authoring/Input/FetchInput.cs b/Unity.Services.Cli/Unity.Services.Cli.Authoring/Input/FetchInput.cs index d5b6deb..01d7dde 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.Authoring/Input/FetchInput.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.Authoring/Input/FetchInput.cs @@ -6,7 +6,7 @@ namespace Unity.Services.Cli.Authoring.Input; /// /// Fetch command input /// -public class FetchInput : CommonInput +public class FetchInput : AuthoringInput { public static readonly Argument PathArgument = new( "path", @@ -14,35 +14,4 @@ public class FetchInput : CommonInput [InputBinding(nameof(PathArgument))] public string Path { get; set; } = string.Empty; - - public static readonly Option ReconcileOption = new( - new[] - { - "--reconcile" - }, - "Content that is not updated will be created at the root."); - - [InputBinding(nameof(ReconcileOption))] - public bool Reconcile { get; set; } = false; - - public static readonly Option> ServiceOptions = new( - new[] - { - "--services", - "-s" - }, - "The name(s) of the service(s) to perform the command on."); - - [InputBinding(nameof(ServiceOptions))] - public IReadOnlyList Services { get; set; } = new List(); - - public static readonly Option DryRunOption = new( - new[] - { - "--dry-run" - }, - "Perform a trial run with no changes made."); - - [InputBinding(nameof(DryRunOption))] - public bool DryRun { get; set; } = false; } diff --git a/Unity.Services.Cli/Unity.Services.Cli.Authoring/Input/NewFileInput.cs b/Unity.Services.Cli/Unity.Services.Cli.Authoring/Input/NewFileInput.cs index 6738fc9..2bfa1bc 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.Authoring/Input/NewFileInput.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.Authoring/Input/NewFileInput.cs @@ -7,7 +7,7 @@ public class NewFileInput : CommonInput { public static readonly Argument FileArgument = new( "file name", - () => "new_file", + () => null!, "The name of the file to create"); [InputBinding(nameof(FileArgument))] diff --git a/Unity.Services.Cli/Unity.Services.Cli.Authoring/Model/DeploymentResult.cs b/Unity.Services.Cli/Unity.Services.Cli.Authoring/Model/DeploymentResult.cs index fda9fa8..b436bd0 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.Authoring/Model/DeploymentResult.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.Authoring/Model/DeploymentResult.cs @@ -18,7 +18,7 @@ public DeploymentResult( { } - public DeploymentResult(IReadOnlyList results) : base(results) { } + public DeploymentResult(IReadOnlyList results, bool dryRun = false) : base(results, dryRun) { } public DeploymentResult(IReadOnlyList results) : base(results) { } diff --git a/Unity.Services.Cli/Unity.Services.Cli.Authoring/Service/DeployFileService.cs b/Unity.Services.Cli/Unity.Services.Cli.Authoring/Service/DeployFileService.cs index 10b7c51..c02bda7 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.Authoring/Service/DeployFileService.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.Authoring/Service/DeployFileService.cs @@ -4,7 +4,7 @@ namespace Unity.Services.Cli.Authoring.Service; -class DeployFileService : IDeployFileService +public class DeployFileService : IDeployFileService { readonly IFile m_File; readonly IDirectory m_Directory; diff --git a/Unity.Services.Cli/Unity.Services.Cli.Authoring/Service/IAuthoringService.cs b/Unity.Services.Cli/Unity.Services.Cli.Authoring/Service/IAuthoringService.cs new file mode 100644 index 0000000..db5396b --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Authoring/Service/IAuthoringService.cs @@ -0,0 +1,8 @@ +namespace Unity.Services.Cli.Authoring.Service; + +public interface IAuthoringService +{ + string ServiceType { get; } + string ServiceName { get; } + IReadOnlyList FileExtensions { get; } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Authoring/Service/IDeploymentService.cs b/Unity.Services.Cli/Unity.Services.Cli.Authoring/Service/IDeploymentService.cs index cea7171..8b20a49 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.Authoring/Service/IDeploymentService.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.Authoring/Service/IDeploymentService.cs @@ -4,12 +4,8 @@ namespace Unity.Services.Cli.Authoring.Service; -public interface IDeploymentService +public interface IDeploymentService : IAuthoringService { - string ServiceType { get; } - string ServiceName { get; } - public string DeployFileExtension { get; } - Task Deploy( DeployInput deployInput, IReadOnlyList filePaths, diff --git a/Unity.Services.Cli/Unity.Services.Cli.Authoring/Service/IFetchService.cs b/Unity.Services.Cli/Unity.Services.Cli.Authoring/Service/IFetchService.cs index 5a5210a..4672feb 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.Authoring/Service/IFetchService.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.Authoring/Service/IFetchService.cs @@ -4,15 +4,13 @@ namespace Unity.Services.Cli.Authoring.Service; -public interface IFetchService +public interface IFetchService : IAuthoringService { - string ServiceType { get; } - string ServiceName { get; } - string FileExtension { get; } - Task FetchAsync( FetchInput input, IReadOnlyList filePaths, + string projectId, + string environmentId, StatusContext? loadingContext, CancellationToken cancellationToken); } diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Authoring/Fetch/JavaScriptFetchServiceTests.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Authoring/Fetch/JavaScriptFetchServiceTests.cs index 3faaf7a..b930678 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Authoring/Fetch/JavaScriptFetchServiceTests.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Authoring/Fetch/JavaScriptFetchServiceTests.cs @@ -8,6 +8,7 @@ using Unity.Services.Cli.Authoring.Input; using Unity.Services.Cli.Authoring.Model; using Unity.Services.Cli.CloudCode.Authoring; +using Unity.Services.Cli.CloudCode.Authoring.Fetch; using Unity.Services.Cli.CloudCode.Deploy; using Unity.Services.Cli.CloudCode.Parameters; using Unity.Services.Cli.CloudCode.Service; @@ -79,7 +80,13 @@ public async Task FetchAsyncInitializesClientAndGetsResultFromHandler(bool dryRu CancellationToken.None)) .ReturnsAsync(expectedResult); - var result = await m_Service.FetchAsync(input, files, null, CancellationToken.None); + var result = await m_Service.FetchAsync( + input, + files, + TestValues.ValidProjectId, + TestValues.ValidEnvironmentId, + null, + CancellationToken.None); m_Client.Verify( x => x.Initialize(TestValues.ValidEnvironmentId, TestValues.ValidProjectId, CancellationToken.None), @@ -109,8 +116,8 @@ void SetupLocalResources(out FetchInput input, out List scripts, out Li m_ScriptsLoader.Setup( x => x.LoadScriptsAsync( filesInstance, - CloudCodeConstants.ServiceType, - CloudCodeConstants.JavaScriptFileExtension, + CloudCodeConstants.ServiceTypeScripts, + CloudCodeConstants.FileExtensionJavaScript, m_InputParser.Object, m_ScriptParser.Object, CancellationToken.None)) @@ -135,8 +142,8 @@ public async Task FailedToLoadAreReported() m_ScriptsLoader.Setup( x => x.LoadScriptsAsync( It.IsAny>(), - CloudCodeConstants.ServiceType, - CloudCodeConstants.JavaScriptFileExtension, + CloudCodeConstants.ServiceTypeScripts, + CloudCodeConstants.FileExtensionJavaScript, m_InputParser.Object, m_ScriptParser.Object, CancellationToken.None)) @@ -168,7 +175,9 @@ public async Task FailedToLoadAreReported() var actualResult = await m_Service.FetchAsync( input, - new [] { "hello.js" }, + new[] { "hello.js" }, + string.Empty, + string.Empty, null!, CancellationToken.None); diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/CloudCodeModuleTests.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/CloudCodeModuleTests.cs index c5cc15c..e6d5c90 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/CloudCodeModuleTests.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/CloudCodeModuleTests.cs @@ -2,12 +2,14 @@ using System.Collections.Generic; using System.CommandLine; using System.CommandLine.Builder; +using System.IO.Abstractions; using System.Linq; using System.Reflection; using Microsoft.Extensions.DependencyInjection; using Moq; using NUnit.Framework; using Unity.Services.Cli.Authoring.Input; +using Unity.Services.Cli.Authoring.Service; using Unity.Services.Cli.CloudCode.Authoring; using Unity.Services.Cli.CloudCode.Deploy; using Unity.Services.Cli.CloudCode.Input; @@ -22,6 +24,7 @@ using Unity.Services.CloudCode.Authoring.Editor.Core.Deployment; using Unity.Services.CloudCode.Authoring.Editor.Core.Logging; using Unity.Services.Gateway.CloudCodeApiV1.Generated.Api; +using IFileSystem = Unity.Services.CloudCode.Authoring.Editor.Core.IO.IFileSystem; namespace Unity.Services.Cli.CloudCode.UnitTest; @@ -200,6 +203,10 @@ public void CreateCSharpDeployServiceCreatesInstanceWithReferencesToProvidedServ var environmentProvider = new Mock(); var csModuleLoader = new Mock(); var client = new Mock(); + var solutionPublisher = new Mock(); + var moduleSZipper = new Mock(); + var fileSystem = new Mock(); + var fileService = new Mock(); var deploymentHandlerWithOutput = new CliCloudCodeDeploymentHandler( client.Object, null!, null!, null!); var provider = new Mock(); @@ -219,6 +226,14 @@ void SetupProvider() .Returns(environmentProvider.Object); provider.Setup(x => x.GetService(typeof(ICSharpClient))) .Returns(client.Object); + provider.Setup(x => x.GetService(typeof(IDeployFileService))) + .Returns(fileService.Object); + provider.Setup(x => x.GetService(typeof(ISolutionPublisher))) + .Returns(solutionPublisher.Object); + provider.Setup(x => x.GetService(typeof(IModuleZipper))) + .Returns(moduleSZipper.Object); + provider.Setup(x => x.GetService(typeof(IFileSystem))) + .Returns(fileSystem.Object); } void AssertExpectedServicesAreWrapped() diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Deploy/CloudCodeModuleDeploymentServiceTests.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Deploy/CloudCodeModuleDeploymentServiceTests.cs new file mode 100644 index 0000000..1c42397 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Deploy/CloudCodeModuleDeploymentServiceTests.cs @@ -0,0 +1,527 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using NUnit.Framework; +using Unity.Services.Cli.Authoring.Service; +using Unity.Services.Cli.CloudCode.Authoring; +using Unity.Services.Cli.CloudCode.Deploy; +using Unity.Services.Cli.CloudCode.Input; +using Unity.Services.Cli.CloudCode.Service; +using Unity.Services.Cli.CloudCode.UnitTest.Utils; +using Unity.Services.Cli.CloudCode.Utils; +using Unity.Services.CloudCode.Authoring.Editor.Core.Deployment; +using Unity.Services.CloudCode.Authoring.Editor.Core.IO; +using Unity.Services.CloudCode.Authoring.Editor.Core.Model; +using Unity.Services.DeploymentApi.Editor; +using Unity.Services.Gateway.CloudCodeApiV1.Generated.Client; +using AuthoringLanguage = Unity.Services.CloudCode.Authoring.Editor.Core.Model.Language; +using CloudCodeModuleScript = Unity.Services.Cli.CloudCode.Deploy.CloudCodeModule; + +namespace Unity.Services.Cli.CloudCode.UnitTest.Deploy; + +[TestFixture] +public class CloudCodeModuleDeploymentServiceTests +{ + static readonly List k_ValidCcmFilePaths = new() + { + "test_a.ccm", + "test_b.ccm" + }; + + static readonly List k_ValidSlnFilePaths = new() + { + "test_sln_a.sln" + }; + + readonly Mock m_MockCloudCodeClient = new(); + readonly Mock m_MockEnvironmentProvider = new(); + readonly Mock m_MockCloudCodeInputParser = new(); + readonly Mock m_MockCloudCodeService = new(); + readonly Mock m_DeploymentHandler = new(); + readonly Mock m_MockCloudCodeModulesLoader = new(); + readonly Mock m_MockDeployFileService = new(); + readonly Mock m_MockSolutionPublisher = new(); + readonly Mock m_MockModuleZipper = new(); + readonly Mock m_MockFileSystem = new(); + + static readonly IReadOnlyList k_DeployedContents = new[] + { + new CloudCodeModuleScript( + "module.ccm", + "path", + 100, + DeploymentStatus.UpToDate) + }; + + static readonly IReadOnlyList k_FailedContents = new[] + { + new CloudCodeModuleScript( + "invalid1.ccm", + "path", + 0, + DeploymentStatus.Empty), + new CloudCodeModuleScript( + "invalid2.ccm", + "path", + 0, + DeploymentStatus.Empty) + }; + + readonly List m_RemoteContents = new() + { + new ScriptInfo("ToDelete", ".ccm") + }; + + CloudCodeModuleDeploymentService? m_DeploymentService; + + [SetUp] + public void SetUp() + { + m_MockCloudCodeClient.Reset(); + m_MockEnvironmentProvider.Reset(); + m_MockCloudCodeInputParser.Reset(); + m_MockCloudCodeService.Reset(); + m_MockCloudCodeModulesLoader.Reset(); + m_DeploymentHandler.Reset(); + m_MockSolutionPublisher.Reset(); + + m_DeploymentHandler.Setup( + c => c.DeployAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Returns( + Task.FromResult( + new DeployResult( + new List(), + new List(), + new List(), + k_DeployedContents, + k_FailedContents))); + + m_DeploymentService = new CloudCodeModuleDeploymentService( + m_DeploymentHandler.Object, + m_MockCloudCodeModulesLoader.Object, + m_MockEnvironmentProvider.Object, + m_MockCloudCodeClient.Object, + m_MockDeployFileService.Object, + m_MockSolutionPublisher.Object, + m_MockModuleZipper.Object, + m_MockFileSystem.Object); + + m_MockCloudCodeModulesLoader.Setup( + c => c.LoadPrecompiledModulesAsync( + k_ValidCcmFilePaths, + CloudCodeConstants.ServiceTypeModules)) + .ReturnsAsync(k_DeployedContents.OfType().ToList()); + } + + [Test] + public async Task DeployAsync_RemovesDuplicatesBeforeDeploy() + { + var outputCcmPath = "test_a.ccm"; + + CloudCodeInput input = new() + { + CloudProjectId = TestValues.ValidProjectId, + Paths = k_ValidSlnFilePaths, + }; + + m_MockCloudCodeModulesLoader.Reset(); + + m_MockDeployFileService.Setup( + c => c.ListFilesToDeploy( + It.IsAny>(), + CloudCodeConstants.FileExtensionModulesCcm, + false)) + .Returns(k_ValidCcmFilePaths); + + m_MockDeployFileService.Setup( + c => c.ListFilesToDeploy( + It.IsAny>(), + CloudCodeConstants.FileExtensionModulesSln, + false)) + .Returns(k_ValidSlnFilePaths); + + var fakeModuleName = "FakeModuleName"; + var testSlnDirName = "FakeSolutionDirName"; + var slnPath = "FakeSolutionPath"; + + m_MockSolutionPublisher.Setup( + x => x.PublishToFolder( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(fakeModuleName); + + m_MockFileSystem.Setup( + x => x.GetDirectoryName(It.IsAny())); + m_MockFileSystem.Setup( + x => x.GetFullPath(It.IsAny())) + .Returns(testSlnDirName); + + m_MockFileSystem.Setup( + x => x.Combine(testSlnDirName, CloudCodeModuleDeploymentService.OutputPath)) + .Returns(slnPath); + + m_MockModuleZipper.Setup( + x => x.ZipCompilation( + It.IsAny(), + fakeModuleName, + It.IsAny())) + .ReturnsAsync(outputCcmPath); + + await m_DeploymentService!.Deploy( + input, + k_ValidCcmFilePaths, + TestValues.ValidProjectId, + TestValues.ValidEnvironmentId, + null!, + CancellationToken.None); + + var slnName = Path.GetFileNameWithoutExtension(k_ValidSlnFilePaths.First()); + var dllOutputPath = Path.Combine(Path.GetTempPath(), slnName); + var moduleCompilationPath = Path.Combine(dllOutputPath, "module-compilation"); + + m_MockSolutionPublisher.Verify( + x => x.PublishToFolder( + k_ValidSlnFilePaths.First(), + moduleCompilationPath, + It.IsAny()), + Times.Once); + + m_MockCloudCodeModulesLoader.Verify( + x => x.LoadPrecompiledModulesAsync( + k_ValidCcmFilePaths, + It.IsAny()), + Times.Once); + } + + [Test] + public async Task DeployAsync_GenerateSolutionFromSlnInput() + { + var outputCcmPath = "test_result.ccm"; + + CloudCodeInput input = new() + { + CloudProjectId = TestValues.ValidProjectId, + Paths = k_ValidSlnFilePaths, + }; + + m_MockCloudCodeModulesLoader.Reset(); + + m_MockDeployFileService.Setup( + c => c.ListFilesToDeploy( + It.IsAny>(), + CloudCodeConstants.FileExtensionModulesCcm, + false)) + .Returns(k_ValidCcmFilePaths); + + m_MockDeployFileService.Setup( + c => c.ListFilesToDeploy( + It.IsAny>(), + CloudCodeConstants.FileExtensionModulesSln, + false)) + .Returns(k_ValidSlnFilePaths); + + var fakeModuleName = "FakeModuleName"; + var testSlnDirName = "FakeSolutionDirName"; + var slnPath = "FakeSolutionPath"; + + m_MockSolutionPublisher.Setup( + x => x.PublishToFolder( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(fakeModuleName); + + m_MockFileSystem.Setup( + x => x.GetDirectoryName(It.IsAny())); + m_MockFileSystem.Setup( + x => x.GetFullPath(It.IsAny())) + .Returns(testSlnDirName); + m_MockFileSystem.Setup( + x => x.Combine(testSlnDirName, CloudCodeModuleDeploymentService.OutputPath)) + .Returns(slnPath); + + m_MockModuleZipper.Setup( + x => x.ZipCompilation( + It.IsAny(), + fakeModuleName, + It.IsAny())) + .ReturnsAsync(outputCcmPath); + + await m_DeploymentService!.Deploy( + input, + k_ValidCcmFilePaths, + TestValues.ValidProjectId, + TestValues.ValidEnvironmentId, + null!, + CancellationToken.None); + + var slnName = Path.GetFileNameWithoutExtension(k_ValidSlnFilePaths.First()); + var dllOutputPath = Path.Combine(Path.GetTempPath(), slnName); + var moduleCompilationPath = Path.Combine(dllOutputPath, "module-compilation"); + + m_MockSolutionPublisher.Verify( + x => x.PublishToFolder( + k_ValidSlnFilePaths.First(), + moduleCompilationPath, + It.IsAny()), + Times.Once); + + var resultList = new List(); + resultList.AddRange(k_ValidCcmFilePaths); + resultList.Add(outputCcmPath); + m_MockCloudCodeModulesLoader.Verify( + x => x.LoadPrecompiledModulesAsync( + resultList, + It.IsAny()), + Times.Once); + } + + [Test] + public async Task DeployAsync_CallsFullPathCorrectly() + { + CloudCodeInput input = new() + { + CloudProjectId = TestValues.ValidProjectId, + Paths = k_ValidCcmFilePaths, + }; + + IScript myModule = new Unity.Services.Cli.CloudCode.Deploy.CloudCodeModule( + new ScriptName("module.ccm"), + Language.JS, + "modules"); + + m_MockCloudCodeModulesLoader.Reset(); + + m_MockDeployFileService.Setup( + c => c.ListFilesToDeploy( + k_ValidCcmFilePaths, + CloudCodeConstants.FileExtensionModulesCcm, + false)) + .Returns(k_ValidCcmFilePaths); + + m_MockDeployFileService.Setup( + c => c.ListFilesToDeploy( + It.IsAny>(), + CloudCodeConstants.FileExtensionModulesSln, + false)) + .Returns(new Collection()); + + var loadedResult = new List + { + myModule + }; + m_MockCloudCodeModulesLoader.Setup( + c => c.LoadPrecompiledModulesAsync( + k_ValidCcmFilePaths, + It.IsAny())) + .ReturnsAsync(loadedResult); + + var result = await m_DeploymentService!.Deploy( + input, + k_ValidCcmFilePaths, + TestValues.ValidProjectId, + TestValues.ValidEnvironmentId, + null!, + CancellationToken.None); + + m_MockCloudCodeClient.Verify( + x => x.Initialize( + TestValues.ValidEnvironmentId, + TestValues.ValidProjectId, + CancellationToken.None), + Times.Once); + m_MockEnvironmentProvider.VerifySet(x => { x.Current = TestValues.ValidEnvironmentId; }, Times.Once); + m_DeploymentHandler.Verify(x => x.DeployAsync(loadedResult, false, false), Times.Once); + Assert.AreEqual(k_DeployedContents, result.Deployed); + Assert.AreEqual(k_FailedContents, result.Failed); + } + + [Test] + public async Task DeployAsync_FailsGeneration() + { + CloudCodeInput input = new() + { + CloudProjectId = TestValues.ValidProjectId, + Paths = k_ValidSlnFilePaths, + }; + + m_MockCloudCodeModulesLoader.Reset(); + + m_MockDeployFileService.Setup( + c => c.ListFilesToDeploy( + It.IsAny>(), + CloudCodeConstants.FileExtensionModulesCcm, + false)) + .Returns(k_ValidCcmFilePaths); + + m_MockDeployFileService.Setup( + c => c.ListFilesToDeploy( + It.IsAny>(), + CloudCodeConstants.FileExtensionModulesSln, + false)) + .Returns(k_ValidSlnFilePaths); + + var testFakeModuleName = "FakeModuleName"; + var testSlnDirName = "FakeSolutionDirName"; + var slnPath = "FakeSolutionPath"; + + m_MockSolutionPublisher.Setup( + x => x.PublishToFolder( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(testFakeModuleName); + m_MockFileSystem.Setup( + x => x.GetDirectoryName(It.IsAny())); + m_MockFileSystem.Setup( + x => x.GetFullPath(It.IsAny())) + .Returns(testSlnDirName); + m_MockFileSystem.Setup( + x => x.Combine(testSlnDirName, CloudCodeModuleDeploymentService.OutputPath)) + .Returns(slnPath); + + m_MockModuleZipper.Setup( + x => x.ZipCompilation( + It.IsAny(), + testFakeModuleName, + It.IsAny())) + .Throws(new Exception("Fake Exception")); + + IScript myModule = new Unity.Services.Cli.CloudCode.Deploy.CloudCodeModule( + new ScriptName("module.ccm"), + Language.JS, + "modules"); + + var loadedResult = new List + { + myModule + }; + m_MockCloudCodeModulesLoader.Setup( + c => c.LoadPrecompiledModulesAsync( + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(loadedResult); + + var result = await m_DeploymentService!.Deploy( + input, + k_ValidCcmFilePaths, + TestValues.ValidProjectId, + TestValues.ValidEnvironmentId, + null!, + CancellationToken.None); + + Assert.AreEqual(result.Failed.Count, k_FailedContents.Count + 1); + Assert.IsTrue(result.Failed.Any(x => x.Name == k_ValidSlnFilePaths.First())); + } + + [Test] + public async Task DeployReconcileAsync_WillCreateDeleteContent() + { + CloudCodeInput input = new() + { + Reconcile = true, + CloudProjectId = TestValues.ValidProjectId, + }; + + var testModules = new[] + { + new CloudCodeModuleScript( + new ScriptName("module.ccm"), + Language.JS, + "modules"), + new CloudCodeModuleScript( + new ScriptName("module2.ccm"), + Language.JS, + "modules") + }; + + m_MockCloudCodeModulesLoader.Reset(); + m_MockCloudCodeModulesLoader.Setup( + c => c.LoadPrecompiledModulesAsync( + k_ValidCcmFilePaths, + CloudCodeConstants.ServiceTypeScripts)) + .ReturnsAsync(testModules.OfType().ToList()); + + m_MockDeployFileService.Setup( + c => c.ListFilesToDeploy( + k_ValidCcmFilePaths, + CloudCodeConstants.FileExtensionModulesCcm, + false)) + .Returns(k_ValidCcmFilePaths); + + m_MockDeployFileService.Setup( + c => c.ListFilesToDeploy( + k_ValidCcmFilePaths, + CloudCodeConstants.FileExtensionModulesSln, + false)) + .Returns(new Collection()); + + m_DeploymentHandler.Setup( + ex => ex.DeployAsync(It.IsAny>(), true, false)) + .Returns( + Task.FromResult( + new DeployResult( + System.Array.Empty(), + System.Array.Empty(), + m_RemoteContents.Select(script => (IScript)script).ToList(), + System.Array.Empty(), + System.Array.Empty()))); + + var result = await m_DeploymentService!.Deploy( + input, + k_ValidCcmFilePaths, + TestValues.ValidProjectId, + TestValues.ValidEnvironmentId, + null!, + CancellationToken.None); + + Assert.IsTrue( + result.Deleted.Any( + item => m_RemoteContents.Any(content => content.Name.ToString() == item.Name))); + } + + [Test] + public void DeployAsync_DoesNotThrowOnApiException() + { + CloudCodeInput input = new() + { + CloudProjectId = TestValues.ValidProjectId + }; + + m_DeploymentHandler.Setup( + ex => ex.DeployAsync(It.IsAny>(), false, false)) + .ThrowsAsync(new ApiException()); + + m_MockDeployFileService.Setup( + c => c.ListFilesToDeploy( + k_ValidCcmFilePaths, + CloudCodeConstants.FileExtensionModulesCcm, + false)) + .Returns(k_ValidCcmFilePaths); + + m_MockDeployFileService.Setup( + c => c.ListFilesToDeploy( + k_ValidCcmFilePaths, + CloudCodeConstants.FileExtensionModulesSln, + false)) + .Returns(new Collection()); + + Assert.DoesNotThrowAsync( + () => m_DeploymentService!.Deploy( + input, + k_ValidCcmFilePaths, + TestValues.ValidProjectId, + TestValues.ValidEnvironmentId, + null!, + CancellationToken.None)); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Deploy/CloudCodePrecompiledModuleDeploymentServiceTests.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Deploy/CloudCodePrecompiledModuleDeploymentServiceTests.cs deleted file mode 100644 index 7bc7585..0000000 --- a/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Deploy/CloudCodePrecompiledModuleDeploymentServiceTests.cs +++ /dev/null @@ -1,224 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Moq; -using NUnit.Framework; -using Unity.Services.Cli.CloudCode.Authoring; -using Unity.Services.Cli.CloudCode.Deploy; -using Unity.Services.Cli.CloudCode.Input; -using Unity.Services.Cli.CloudCode.Service; -using Unity.Services.Cli.CloudCode.UnitTest.Utils; -using Unity.Services.Cli.CloudCode.Utils; -using Unity.Services.CloudCode.Authoring.Editor.Core.Deployment; -using Unity.Services.CloudCode.Authoring.Editor.Core.Model; -using Unity.Services.DeploymentApi.Editor; -using Unity.Services.Gateway.CloudCodeApiV1.Generated.Client; -using AuthoringLanguage = Unity.Services.CloudCode.Authoring.Editor.Core.Model.Language; -using CloudCodeModuleScript = Unity.Services.Cli.CloudCode.Deploy.CloudCodeModule; - -namespace Unity.Services.Cli.CloudCode.UnitTest.Deploy; - -[TestFixture] -public class CloudCodePrecompiledModuleDeploymentServiceTests -{ - static readonly List k_ValidFilePaths = new() - { - "test_a.ccm", - "test_b.ccm" - }; - - readonly Mock m_MockCloudCodeClient = new(); - readonly Mock m_MockEnvironmentProvider = new(); - readonly Mock m_MockCloudCodeInputParser = new(); - readonly Mock m_MockCloudCodeService = new(); - readonly Mock m_DeploymentHandler = new(); - readonly Mock m_MockCloudCodeModulesLoader = new(); - static readonly IReadOnlyList k_DeployedContents = new[] - { - new CloudCodeModuleScript( - "module.ccm", - "path", - 100, - DeploymentStatus.UpToDate) - }; - - static readonly IReadOnlyList k_FailedContents = new[] - { - new CloudCodeModuleScript( - "invalid1.ccm", - "path", - 0, - DeploymentStatus.Empty), - new CloudCodeModuleScript( - "invalid2.ccm", - "path", - 0, - DeploymentStatus.Empty) - }; - - readonly List m_RemoteContents = new() - { - new ScriptInfo("ToDelete", ".ccm") - }; - - readonly List m_Contents = k_DeployedContents.Concat(k_FailedContents).ToList(); - - CloudCodePrecompiledModuleDeploymentService? m_DeploymentService; - - [SetUp] - public void SetUp() - { - m_MockCloudCodeClient.Reset(); - m_MockEnvironmentProvider.Reset(); - m_MockCloudCodeInputParser.Reset(); - m_MockCloudCodeService.Reset(); - m_MockCloudCodeModulesLoader.Reset(); - m_DeploymentHandler.Reset(); - - m_DeploymentHandler.Setup( - c => c.DeployAsync( - It.IsAny>(), - It.IsAny(), - It.IsAny())) - .Returns( - Task.FromResult( - new DeployResult( - new List(), - new List(), - new List(), - k_DeployedContents, - k_FailedContents))); - - m_DeploymentService = new CloudCodePrecompiledModuleDeploymentService( - m_DeploymentHandler.Object, - m_MockCloudCodeModulesLoader.Object, - m_MockEnvironmentProvider.Object, - m_MockCloudCodeClient.Object); - - m_MockCloudCodeModulesLoader.Setup( - c => c.LoadPrecompiledModulesAsync( - k_ValidFilePaths, - CloudCodeConstants.ServiceTypeModules)) - .ReturnsAsync(k_DeployedContents.OfType().ToList()); - } - - [Test] - public async Task DeployAsync_CallsLoadFilePathsFromInputCorrectly() - { - CloudCodeInput input = new() - { - CloudProjectId = TestValues.ValidProjectId, - Paths = k_ValidFilePaths, - }; - - IScript myModule = new Unity.Services.Cli.CloudCode.Deploy.CloudCodeModule( - new ScriptName("module.ccm"), - Language.JS, - "modules"); - - m_MockCloudCodeModulesLoader.Reset(); - m_MockCloudCodeModulesLoader.Setup( - c => c.LoadPrecompiledModulesAsync( - k_ValidFilePaths, - It.IsAny())) - .ReturnsAsync( - new List - { - myModule - }); - - var result = await m_DeploymentService!.Deploy( - input, - k_ValidFilePaths, - TestValues.ValidProjectId, - TestValues.ValidEnvironmentId, - null!, - CancellationToken.None); - - m_MockCloudCodeClient.Verify( - x => x.Initialize( - TestValues.ValidEnvironmentId, - TestValues.ValidProjectId, - CancellationToken.None), - Times.Once); - m_MockEnvironmentProvider.VerifySet(x => { x.Current = TestValues.ValidEnvironmentId; }, Times.Once); - m_DeploymentHandler.Verify(x => x.DeployAsync(It.IsAny>(), false, false), Times.Once); - Assert.AreEqual(k_DeployedContents, result.Deployed); - Assert.AreEqual(k_FailedContents, result.Failed); - } - - [Test] - public async Task DeployReconcileAsync_WillCreateDeleteContent() - { - CloudCodeInput input = new() - { - Reconcile = true, - CloudProjectId = TestValues.ValidProjectId, - }; - - var testModules = new [] - { - new CloudCodeModuleScript( - new ScriptName("module.ccm"), - Language.JS, - "modules"), - new CloudCodeModuleScript( - new ScriptName("module2.ccm"), - Language.JS, - "modules") - }; - - m_MockCloudCodeModulesLoader.Reset(); - m_MockCloudCodeModulesLoader.Setup( - c => c.LoadPrecompiledModulesAsync( - k_ValidFilePaths, - CloudCodeConstants.ServiceType)) - .ReturnsAsync(testModules.OfType().ToList()); - - m_DeploymentHandler.Setup( - ex => ex.DeployAsync(It.IsAny>(), true, false)) - .Returns( - Task.FromResult( - new DeployResult( - System.Array.Empty(), - System.Array.Empty(), - m_RemoteContents.Select(script => (IScript)script).ToList(), - System.Array.Empty(), - System.Array.Empty()))); - - var result = await m_DeploymentService!.Deploy( - input, - k_ValidFilePaths, - TestValues.ValidProjectId, - TestValues.ValidEnvironmentId, - null!, - CancellationToken.None); - - Assert.IsTrue( - result.Deleted.Any( - item => m_RemoteContents.Any(content => content.Name.ToString() == item.Name))); - } - - [Test] - public void DeployAsync_DoesNotThrowOnApiException() - { - CloudCodeInput input = new() - { - CloudProjectId = TestValues.ValidProjectId - }; - - m_DeploymentHandler.Setup( - ex => ex.DeployAsync(It.IsAny>(), false, false)) - .ThrowsAsync(new ApiException()); - - Assert.DoesNotThrowAsync( - () => m_DeploymentService!.Deploy( - input, - k_ValidFilePaths, - TestValues.ValidProjectId, - TestValues.ValidEnvironmentId, - null!, - CancellationToken.None)); - } -} diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Deploy/CloudCodeScriptsLoaderTests.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Deploy/CloudCodeScriptsLoaderTests.cs index fe7dece..2ba9414 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Deploy/CloudCodeScriptsLoaderTests.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Deploy/CloudCodeScriptsLoaderTests.cs @@ -47,7 +47,7 @@ public async Task LoadScriptAsyncReturnScriptList() .ReturnsAsync(scriptCode); m_MockCloudCodeScriptParser.Setup(c => c.ParseScriptParametersAsync(scriptCode, CancellationToken.None)) .ReturnsAsync(new ParseScriptParametersResult(false, expectedParameters)); - var loadResult = await m_CodeScriptsLoader.LoadScriptsAsync(paths, CloudCodeConstants.ServiceType, ".js", + var loadResult = await m_CodeScriptsLoader.LoadScriptsAsync(paths, CloudCodeConstants.ServiceTypeScripts, ".js", m_MockCloudCodeInputParser.Object, m_MockCloudCodeScriptParser.Object, CancellationToken.None); Assert.That(loadResult.LoadedScripts.Count, Is.EqualTo(1)); Assert.That(loadResult.LoadedScripts[0].Parameters.Count, Is.EqualTo(expectedParameters.Count)); @@ -70,7 +70,7 @@ public void LoadScriptAsyncCatchScriptEvaluationException() m_MockCloudCodeInputParser.Setup(c => c.LoadScriptCodeAsync("script1.js", CancellationToken.None)) .ThrowsAsync(new ScriptEvaluationException("Fail to parse script")); Assert.DoesNotThrowAsync(async () => - await m_CodeScriptsLoader.LoadScriptsAsync(paths, CloudCodeConstants.ServiceType, ".js", + await m_CodeScriptsLoader.LoadScriptsAsync(paths, CloudCodeConstants.ServiceTypeScripts, ".js", m_MockCloudCodeInputParser.Object, m_MockCloudCodeScriptParser.Object, CancellationToken.None)); } diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Handlers/ExportModulesHandlerTests.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Handlers/ExportModulesHandlerTests.cs index 33e82f6..856e4ff 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Handlers/ExportModulesHandlerTests.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Handlers/ExportModulesHandlerTests.cs @@ -94,7 +94,7 @@ public async Task ExportAsync_ExportsAndZips() { var exportInput = new ExportInput() { - FileName = CloudCodeConstants.ModulesZipName, + FileName = CloudCodeConstants.ZipNameModules, OutputDirectory = "test_output_directory" }; @@ -128,7 +128,7 @@ public async Task ExportAsync_ExportsAndZips() var archivePath = Path.Join(exportInput.OutputDirectory, exportInput.FileName); m_MockArchiver.Verify(za => za.ZipAsync( archivePath, - CloudCodeConstants.ModulesEntryName, + CloudCodeConstants.EntryNameModules, It.Is>(m => m.Count() == m_Modules.Count()), It.IsAny())); } diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Handlers/ExportScriptsHandlerTests.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Handlers/ExportScriptsHandlerTests.cs index 2907ef4..2f203d9 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Handlers/ExportScriptsHandlerTests.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Handlers/ExportScriptsHandlerTests.cs @@ -96,7 +96,7 @@ public async Task ExportAsync_ExportsAndZips() OutputDirectory = "mock_output_directory" }; - var archivePath = Path.Join(exportInput.OutputDirectory, CloudCodeConstants.JavascriptZipName); + var archivePath = Path.Join(exportInput.OutputDirectory, CloudCodeConstants.ZipNameJavaScript); m_MockFileSystem .Setup(x => @@ -140,7 +140,7 @@ public async Task ExportAsync_ExportsAndZips() } m_MockFileSystem.Setup(s => s.Directory.CreateDirectory(exportInput.OutputDirectory)); - m_MockFileSystem.Setup(s => s.Path.Join(exportInput.OutputDirectory, CloudCodeConstants.JavascriptZipName)) + m_MockFileSystem.Setup(s => s.Path.Join(exportInput.OutputDirectory, CloudCodeConstants.ZipNameJavaScript)) .Returns(archivePath); m_MockFileSystem.Setup(s => s.File.Exists(archivePath)); @@ -162,7 +162,7 @@ public async Task ExportAsync_ExportsAndZips() m_MockArchiver.Verify(za => za.ZipAsync( It.Is(s => s == archivePath), - It.Is(s => s == CloudCodeConstants.ScriptsEntryName), + It.Is(s => s == CloudCodeConstants.EntryNameScripts), It.Is>(s => AssertAreEqual(s.ToList(), m_Scripts.ToList())), It.IsAny())); } diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Handlers/ImportModulesHandlerTests.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Handlers/ImportModulesHandlerTests.cs index f3253a7..8335d02 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Handlers/ImportModulesHandlerTests.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Handlers/ImportModulesHandlerTests.cs @@ -141,11 +141,11 @@ public async Task ImportAsync_Unzips() await ImportInternalAsync(importInput); - var archivePath = Path.Join(importInput.InputDirectory, CloudCodeConstants.ModulesZipName); + var archivePath = Path.Join(importInput.InputDirectory, CloudCodeConstants.ZipNameModules); m_MockArchiver.Verify(za => za.UnzipAsync( archivePath, - CloudCodeConstants.ModulesEntryName, + CloudCodeConstants.EntryNameModules, It.IsAny())); } diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Handlers/ImportScriptsHandlerTests.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Handlers/ImportScriptsHandlerTests.cs index 3da9ae4..0e4bd64 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Handlers/ImportScriptsHandlerTests.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Handlers/ImportScriptsHandlerTests.cs @@ -127,11 +127,11 @@ public async Task ImportAsync_Unzips() await ImportInternalAsync(importInput); - var archivePath = Path.Join(importInput.InputDirectory, CloudCodeConstants.JavascriptZipName); + var archivePath = Path.Join(importInput.InputDirectory, CloudCodeConstants.ZipNameJavaScript); m_MockArchiver.Verify(za => za.UnzipAsync( archivePath, - CloudCodeConstants.ScriptsEntryName, CancellationToken.None)); + CloudCodeConstants.EntryNameScripts, CancellationToken.None)); } [Test] diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/IO/AssemblyLoaderTests.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/IO/AssemblyLoaderTests.cs new file mode 100644 index 0000000..c5795aa --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/IO/AssemblyLoaderTests.cs @@ -0,0 +1,22 @@ +using NUnit.Framework; +using Unity.Services.Cli.CloudCode.IO; +using Unity.Services.Cli.CloudCode.Solution; + +namespace Unity.Services.Cli.CloudCode.UnitTest.IO; + +class AssemblyLoaderTests +{ + AssemblyLoader m_AssemblyLoader; + + public AssemblyLoaderTests() + { + m_AssemblyLoader = new AssemblyLoader(); + } + + [Test] + public void LoadsCorrectAssembly() + { + var assembly = m_AssemblyLoader.Load(FileContentRetriever.AssemblyString); + Assert.IsTrue(assembly.FullName?.StartsWith(FileContentRetriever.AssemblyString)); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/IO/CloudCodeFileStreamTests.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/IO/CloudCodeFileStreamTests.cs new file mode 100644 index 0000000..8fffe89 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/IO/CloudCodeFileStreamTests.cs @@ -0,0 +1,73 @@ +using System; +using System.IO; +using System.IO.Abstractions; +using NUnit.Framework; +using Unity.Services.Cli.CloudCode.IO; + +namespace Unity.Services.Cli.CloudCode.UnitTest.IO; + +class CloudCodeFileStreamTests +{ + const string k_TestFilePath = "something.testfile"; + + FileSystemStream m_FileStream; + CloudCodeFileStream m_CloudCodeFileStream; + + class TestFileStream : FileSystemStream + { + public TestFileStream(Stream stream, string path, bool isAsync) + : base(stream, path, isAsync) { } + } + + public CloudCodeFileStreamTests() + { + m_FileStream = new TestFileStream(File.Create(k_TestFilePath), k_TestFilePath, false); + m_CloudCodeFileStream = new CloudCodeFileStream(m_FileStream); + } + + [TearDown] + public void TearDown() + { + m_FileStream.Close(); + File.Delete(k_TestFilePath); + } + + [Test] + public void ConstructorWorks() + { + Assert.AreEqual(m_CloudCodeFileStream.FileStream, m_FileStream); + } + + [Test] + public void CloseWorks() + { + m_CloudCodeFileStream.Close(); + + try + { + var fileStream = File.Open(k_TestFilePath, FileMode.Open); + fileStream.Close(); + } + catch (IOException) + { + Assert.Fail(); + } + } + + [Test] + public void OpenFailsWhenCloseNotCalled() + { + var ioExceptionThrown = false; + try + { + var fileStream = File.Open(k_TestFilePath, FileMode.Open); + fileStream.Close(); + } + catch (IOException) + { + ioExceptionThrown = true; + } + + Assert.IsTrue(ioExceptionThrown); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/IO/FileSystemTests.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/IO/FileSystemTests.cs new file mode 100644 index 0000000..25c1aac --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/IO/FileSystemTests.cs @@ -0,0 +1,69 @@ +using System.IO.Abstractions; +using Moq; +using NUnit.Framework; +using FileSystem = Unity.Services.Cli.CloudCode.IO.FileSystem; + +namespace Unity.Services.Cli.CloudCode.UnitTest.IO; + +class FileSystemTests +{ + const string k_TestFilePath = "something.testfile"; + const string k_TestDirectoryPath = "some/directory/path"; + + Mock m_MockFile; + Mock m_MockPath; + Mock m_MockDirectory; + FileSystem m_FileSystem; + + public FileSystemTests() + { + m_MockFile = new Mock(); + m_MockPath = new Mock(); + m_MockDirectory = new Mock(); + m_FileSystem = new FileSystem( + m_MockFile.Object, + m_MockPath.Object, + m_MockDirectory.Object); + } + + [Test] + public void CreateFile() + { + m_FileSystem.CreateFile(k_TestFilePath); + m_MockFile.Verify(f => f.Create(k_TestFilePath), Times.Once); + } + + [Test] + public void FileExists() + { + m_FileSystem.FileExists(k_TestFilePath); + m_MockFile.Verify(f => f.Exists(k_TestFilePath), Times.Once); + } + + [Test] + public void DirectoryExists() + { + m_FileSystem.DirectoryExists(k_TestDirectoryPath); + m_MockDirectory.Verify(d => d.Exists(k_TestDirectoryPath)); + } + + [Test] + public void GetDirectoryName() + { + m_FileSystem.GetDirectoryName(k_TestFilePath); + m_MockPath.Verify(p => p.GetDirectoryName(k_TestFilePath), Times.Once); + } + + [Test] + public void Combine() + { + var paths = new[] + { + "1", + "2", + "3" + }; + m_FileSystem.Combine(paths); + m_MockPath.Verify(p => p.Combine(paths), Times.Once); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Service/CloudCodeInputParserTests.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Service/CloudCodeInputParserTests.cs index a2ac7e7..12f61a9 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Service/CloudCodeInputParserTests.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Service/CloudCodeInputParserTests.cs @@ -159,4 +159,15 @@ public void LoadScriptCodeFileNotFoundFailed() }; Assert.ThrowsAsync(() => m_CloudCodeInputParser.LoadScriptCodeAsync(input, CancellationToken.None)); } + + [Test] + public void LoadScriptCodeFileNotFoundWithCorrectPathFailed() + { + Directory.CreateDirectory(k_TempDirectory); + var input = new CloudCodeInput + { + FilePath = k_TempDirectory + '/' + }; + Assert.ThrowsAsync(() => m_CloudCodeInputParser.LoadScriptCodeAsync(input, CancellationToken.None)); + } } diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Solution/FileContentRetrieverTests.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Solution/FileContentRetrieverTests.cs new file mode 100644 index 0000000..7b4ac2f --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Solution/FileContentRetrieverTests.cs @@ -0,0 +1,76 @@ +using System.IO; +using System.Reflection; +using System.Threading.Tasks; +using Moq; +using NUnit.Framework; +using Unity.Services.Cli.CloudCode.IO; +using Unity.Services.Cli.CloudCode.Solution; + +namespace Unity.Services.Cli.CloudCode.UnitTest.Solution; + +class FileContentRetrieverTests +{ + Mock m_MockAssemblyLoader; + FileContentRetriever m_FileContentRetriever; + + public FileContentRetrieverTests() + { + m_MockAssemblyLoader = new Mock(); + m_MockAssemblyLoader + .Setup(al => al.Load(It.IsAny())) + .Returns(Assembly.Load(FileContentRetriever.AssemblyString)); + m_FileContentRetriever = new FileContentRetriever(m_MockAssemblyLoader.Object); + } + + class TestAssembly : Assembly { } + + [Test] + public void GetFileContent_InvalidAssembly() + { + const string invalidAssemblyName = "invalid.assembly.name"; + m_MockAssemblyLoader + .Setup(al => al.Load(invalidAssemblyName)) + .Returns(new TestAssembly()); + Assert.ThrowsAsync(() => m_FileContentRetriever.GetFileContent("some.invalid.path")); + } + + [Test] + public async Task GetFileContent_CanLoadSolution() + { + var templateInfo = new TemplateInfo(); + var fileContent = await m_FileContentRetriever.GetFileContent(templateInfo.PathSolution); + Assert.IsFalse(string.IsNullOrEmpty(fileContent)); + } + + [Test] + public async Task GetFileContent_CanLoadProject() + { + var templateInfo = new TemplateInfo(); + var fileContent = await m_FileContentRetriever.GetFileContent(templateInfo.PathConfig); + Assert.IsFalse(string.IsNullOrEmpty(fileContent)); + } + + [Test] + public async Task GetFileContent_CanLoadExample() + { + var templateInfo = new TemplateInfo(); + var fileContent = await m_FileContentRetriever.GetFileContent(templateInfo.PathExampleClass); + Assert.IsFalse(string.IsNullOrEmpty(fileContent)); + } + + [Test] + public async Task GetFileContent_CanLoadConfig() + { + var templateInfo = new TemplateInfo(); + var fileContent = await m_FileContentRetriever.GetFileContent(templateInfo.PathConfig); + Assert.IsFalse(string.IsNullOrEmpty(fileContent)); + } + + [Test] + public async Task GetFileContent_CanLoadConfigUser() + { + var templateInfo = new TemplateInfo(); + var fileContent = await m_FileContentRetriever.GetFileContent(templateInfo.PathConfigUser); + Assert.IsFalse(string.IsNullOrEmpty(fileContent)); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Authoring/Fetch/JavaScriptFetchService.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Authoring/Fetch/JavaScriptFetchService.cs index 4235c10..236801d 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Authoring/Fetch/JavaScriptFetchService.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Authoring/Fetch/JavaScriptFetchService.cs @@ -9,7 +9,7 @@ using Unity.Services.Cli.Common.Utils; using Unity.Services.DeploymentApi.Editor; -namespace Unity.Services.Cli.CloudCode.Authoring; +namespace Unity.Services.Cli.CloudCode.Authoring.Fetch; class JavaScriptFetchService : IFetchService { @@ -41,19 +41,23 @@ public JavaScriptFetchService( m_FetchHandler = fetchHandler; } - public string ServiceType => CloudCodeConstants.ServiceType; - public string ServiceName => CloudCodeConstants.ServiceName; + public string ServiceType => CloudCodeConstants.ServiceTypeScripts; + public string ServiceName => CloudCodeConstants.ServiceNameScripts; - string IFetchService.FileExtension => CloudCodeConstants.JavaScriptFileExtension; + public IReadOnlyList FileExtensions => new[] + { + CloudCodeConstants.FileExtensionJavaScript + }; public async Task FetchAsync( FetchInput input, IReadOnlyList filePaths, + string projectId, + string environmentId, StatusContext? loadingContext, CancellationToken cancellationToken) { - var environmentId = await m_UnityEnvironment.FetchIdentifierAsync(cancellationToken); - m_Client.Initialize(environmentId, input.CloudProjectId!, cancellationToken); + m_Client.Initialize(environmentId, projectId, cancellationToken); loadingContext?.Status($"Reading {ServiceType} files..."); var loadResult = await GetResourcesFromFilesAsync(filePaths, cancellationToken); @@ -67,10 +71,10 @@ public async Task FetchAsync( cancellationToken); result = new FetchResult( - created:result.Created, + created: result.Created, updated: result.Updated, deleted: result.Deleted, - authored:result.Fetched, + authored: result.Fetched, failed: result.Failed.Concat(loadResult.FailedContents.Cast()).ToList(), dryRun: input.DryRun); @@ -84,7 +88,7 @@ internal async Task GetResourcesFromFilesAsync( .LoadScriptsAsync( filePaths, ServiceType, - CloudCodeConstants.JavaScriptFileExtension, + CloudCodeConstants.FileExtensionJavaScript, m_InputParser, m_ScriptParser, cancellationToken); diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudCode/CloudCodeModule.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudCode/CloudCodeModule.cs index 56aacfe..5178136 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.CloudCode/CloudCodeModule.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudCode/CloudCodeModule.cs @@ -1,4 +1,5 @@ using System.CommandLine; +using System.IO.Abstractions; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Unity.Services.Cli.Authoring.Compression; @@ -24,9 +25,18 @@ using Unity.Services.Cli.Authoring.Handlers; using Unity.Services.Cli.CloudCode.Handlers.ImportExport.Scripts; using Unity.Services.Cli.Authoring.Import.Input; +using Unity.Services.Cli.CloudCode.Authoring.Fetch; using Unity.Services.Cli.CloudCode.Handlers.ImportExport.Modules; +using Unity.Services.Cli.CloudCode.Handlers.NewFile; +using Unity.Services.Cli.CloudCode.IO; +using Unity.Services.Cli.CloudCode.Solution; using Unity.Services.Cli.CloudCode.Templates; using Unity.Services.Cli.CloudCode.Utils; +using Unity.Services.CloudCode.Authoring.Editor.Core.Dotnet; +using Unity.Services.CloudCode.Authoring.Editor.Core.IO; +using Unity.Services.CloudCode.Authoring.Editor.Core.Solution; +using FileSystem = Unity.Services.Cli.CloudCode.IO.FileSystem; +using IFileSystem = Unity.Services.CloudCode.Authoring.Editor.Core.IO.IFileSystem; using ILogger = Microsoft.Extensions.Logging.ILogger; namespace Unity.Services.Cli.CloudCode; @@ -171,7 +181,7 @@ public CloudCodeModule() CancellationToken>( UpdateHandler.UpdateAsync); - NewFileCommand = ModuleRootCommand.AddNewFileCommand(CloudCodeConstants.ServiceType); + NewFileCommand = ModuleRootCommand.AddNewFileCommand(CloudCodeConstants.ServiceTypeScripts); ScriptsCommand = new Command( "scripts", @@ -256,7 +266,7 @@ static void RegisterModulesCommands(Command root) ILoadingIndicator, CancellationToken>(ModuleExportHandler.ExportAsync); - var importModulesCommand = new Command("import", "Import Cloud Code modules.") + var importModulesCommand = new Command("import", "Import Cloud-Code modules.") { CommonInput.CloudProjectIdOption, CommonInput.EnvironmentNameOption, @@ -272,6 +282,24 @@ static void RegisterModulesCommands(Command root) CancellationToken>( ModulesImportHandler.ImportAsync); + var newFileCommand = new Command( + "new-file", + "Create new Cloud-Code module.") + { + CloudCodeInput.ModuleNameArgument, + CloudCodeInput.ModuleDirectoryArgument, + CommonInput.UseForceOption + }; + newFileCommand.SetHandler< + CloudCodeInput, + IPath, + IDirectory, + CloudCodeModuleSolutionGenerator, + ILogger, + ILoadingIndicator, + CancellationToken>( + NewFileModuleHandler.CreateNewModule); + var modulesHandlerCommand = new Command( "modules", "Manage Cloud-Code modules.") @@ -280,7 +308,8 @@ static void RegisterModulesCommands(Command root) listModuleCommand, deleteModuleCommand, exportModulesCommand, - importModulesCommand + importModulesCommand, + newFileCommand }; modulesHandlerCommand.AddAlias("m"); @@ -320,7 +349,7 @@ public static void RegisterServices(HostBuilderContext hostBuilderContext, IServ serviceCollection.AddSingleton, CloudCodeScriptNameComparer>(); serviceCollection.AddTransient(CreateJavaScriptDeployService); - serviceCollection.AddTransient(CreateCSharpDeployService); + serviceCollection.AddTransient(CreateCSharpDeployService); serviceCollection.AddTransient(); serviceCollection.AddTransient(); @@ -329,6 +358,19 @@ public static void RegisterServices(HostBuilderContext hostBuilderContext, IServ serviceCollection.AddTransient(); serviceCollection.AddTransient(); + + serviceCollection.AddTransient(); + serviceCollection.AddTransient(); + serviceCollection.AddTransient(); + serviceCollection.AddTransient(); + serviceCollection.AddTransient(); + serviceCollection.AddTransient(); + serviceCollection.AddTransient(); + serviceCollection.AddTransient(); + serviceCollection.AddTransient(); + serviceCollection.AddTransient(); + serviceCollection.AddTransient(); + serviceCollection.AddTransient(); } internal static CloudCodeScriptDeploymentService CreateJavaScriptDeployService(IServiceProvider provider) @@ -342,12 +384,16 @@ internal static CloudCodeScriptDeploymentService CreateJavaScriptDeployService(I provider.GetRequiredService()); } - internal static CloudCodePrecompiledModuleDeploymentService CreateCSharpDeployService(IServiceProvider provider) + internal static CloudCodeModuleDeploymentService CreateCSharpDeployService(IServiceProvider provider) { - return new CloudCodePrecompiledModuleDeploymentService( + return new CloudCodeModuleDeploymentService( provider.GetRequiredService>(), provider.GetRequiredService(), provider.GetRequiredService(), - provider.GetRequiredService()); + provider.GetRequiredService(), + provider.GetRequiredService(), + provider.GetRequiredService(), + provider.GetRequiredService(), + provider.GetRequiredService()); } } diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Deploy/CloudCodeModuleDeploymentService.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Deploy/CloudCodeModuleDeploymentService.cs new file mode 100644 index 0000000..3917d58 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Deploy/CloudCodeModuleDeploymentService.cs @@ -0,0 +1,263 @@ +using Spectre.Console; +using Unity.Services.Cli.Authoring.Input; +using Unity.Services.Cli.Authoring.Model; +using Unity.Services.Cli.Authoring.Service; +using Unity.Services.Cli.CloudCode.Authoring; +using Unity.Services.Cli.CloudCode.Model; +using Unity.Services.Cli.CloudCode.Utils; +using Unity.Services.CloudCode.Authoring.Editor.Core.Deployment; +using Unity.Services.CloudCode.Authoring.Editor.Core.IO; +using Unity.Services.CloudCode.Authoring.Editor.Core.Model; +using Unity.Services.DeploymentApi.Editor; +using Unity.Services.Gateway.CloudCodeApiV1.Generated.Client; + +namespace Unity.Services.Cli.CloudCode.Deploy; + +class CloudCodeModuleDeploymentService : IDeploymentService +{ + internal ICloudCodeModulesLoader CloudCodeModulesLoader { get; } + internal ICliEnvironmentProvider EnvironmentProvider { get; } + internal ICSharpClient CliCloudCodeClient { get; } + ICloudCodeDeploymentHandler CloudCodeDeploymentHandler { get; } + + public string ServiceType => m_ServiceType; + + public string ServiceName => m_ServiceName; + + public static string OutputPath = "module-compilation"; + + public IReadOnlyList FileExtensions { get; } = new[] + { + CloudCodeConstants.FileExtensionModulesCcm, + CloudCodeConstants.FileExtensionModulesSln, + }; + + readonly string m_ServiceType; + readonly string m_ServiceName; + readonly IDeployFileService m_DeployFileService; + readonly ISolutionPublisher m_SolutionPublisher; + readonly IModuleZipper m_ModuleZipper; + readonly IFileSystem m_FileSystem; + + public CloudCodeModuleDeploymentService( + ICloudCodeDeploymentHandler deployHandler, + ICloudCodeModulesLoader cloudCodeModulesLoader, + ICliEnvironmentProvider environmentProvider, + ICSharpClient client, + IDeployFileService deployFileService, + ISolutionPublisher solutionPublisher, + IModuleZipper moduleZipper, + IFileSystem fileSystem) + { + CloudCodeModulesLoader = cloudCodeModulesLoader; + EnvironmentProvider = environmentProvider; + CliCloudCodeClient = client; + CloudCodeDeploymentHandler = deployHandler; + m_DeployFileService = deployFileService; + m_SolutionPublisher = solutionPublisher; + m_ModuleZipper = moduleZipper; + m_FileSystem = fileSystem; + + m_ServiceType = CloudCodeConstants.ServiceTypeModules; + m_ServiceName = CloudCodeConstants.ServiceNameModules; + } + + public async Task Deploy( + DeployInput deployInput, + IReadOnlyList filePaths, + string projectId, + string environmentId, + StatusContext? loadingContext, + CancellationToken cancellationToken) + { + CliCloudCodeClient.Initialize(environmentId, projectId, cancellationToken); + EnvironmentProvider.Current = environmentId; + + loadingContext?.Status($"Reading {m_ServiceType}..."); + + var (ccmFilePaths, slnFilePaths) = ListFilesToDeploy(filePaths.ToList()); + + var failedResultList = new List(); + + if (slnFilePaths.Count > 0) + { + loadingContext?.Status("Generating Cloud Code Modules for solution files..."); + + var (generatedCcmFilePaths, failedGenerationResult) = + await CompileModules(slnFilePaths, cancellationToken); + + ccmFilePaths.AddRange(generatedCcmFilePaths); + ccmFilePaths = ccmFilePaths.Distinct().ToList(); + + failedResultList.AddRange(failedGenerationResult); + } + + loadingContext?.Status($"Loading {m_ServiceName} modules..."); + + var loadResult = await CloudCodeModulesLoader.LoadPrecompiledModulesAsync( + ccmFilePaths, + m_ServiceType); + + loadingContext?.Status($"Deploying {m_ServiceType}..."); + + var dryrun = deployInput.DryRun; + var reconcile = deployInput.Reconcile; + DeployResult result = null!; + + try + { + result = await CloudCodeDeploymentHandler.DeployAsync(loadResult, reconcile, dryrun); + failedResultList.AddRange(result.Failed); + } + catch (ApiException) + { + /* + * Ignoring this because we already catch exceptions from UpdateScriptStatus() for each script and we don't + * want to stop execution when a script generates an exception. + */ + } + catch (DeploymentException ex) + { + result = ex.Result; + } + + return ConstructResult(loadResult, result, deployInput, failedResultList); + } + + static CloudCodeModule SetUpFailedCloudCodeModule(ModuleGenerationResult failedGenerationSolution) + { + return new CloudCodeModule( + ScriptName.FromPath(failedGenerationSolution.SolutionPath).ToString(), + failedGenerationSolution.SolutionPath, + 0, + new DeploymentStatus(Statuses.FailedToRead, "Could not generate module for solution.")); + } + + async Task<(List, List)> CompileModules(List slnFilePaths, CancellationToken cancellationToken) + { + var ccmFilePaths = new List(); + var failedToGenerateList = new List(); + var generateTasks = new List>(); + foreach (var slnFilePath in slnFilePaths) + { + generateTasks.Add(CreateCloudCodeModuleFromSolution(slnFilePath, cancellationToken)); + } + + if (generateTasks.Count > 0) + { + var generationResultList = await Task.WhenAll(generateTasks); + foreach (var generationResult in generationResultList) + { + if (generationResult.Success) + { + ccmFilePaths.Add(generationResult.CcmPath); + } + else + { + failedToGenerateList.Add(SetUpFailedCloudCodeModule(generationResult)); + } + } + } + + return (ccmFilePaths, failedToGenerateList); + } + + (List, List) ListFilesToDeploy(List filePaths) + { + List ccmFilePaths = new List(); + List slnFilePaths = new List(); + if (filePaths.Count > 0) + { + ccmFilePaths = m_DeployFileService.ListFilesToDeploy( + filePaths, + CloudCodeConstants.FileExtensionModulesCcm, + false).ToList(); + slnFilePaths = m_DeployFileService.ListFilesToDeploy( + filePaths, + CloudCodeConstants.FileExtensionModulesSln, + false).ToList(); + } + + return (ccmFilePaths, slnFilePaths); + } + + static DeploymentResult ConstructResult(List loadResult, DeployResult? result, DeployInput deployInput, List failedModules) + { + DeploymentResult deployResult; + if (result == null) + { + deployResult = new DeploymentResult(loadResult.OfType().ToList()); + } + else + { + deployResult = new DeploymentResult( + result.Updated.Select(item => item as IDeploymentItem).ToList() as IReadOnlyList, + ToDeleteDeploymentItems(result.Deleted, deployInput.DryRun), + result.Created.Select(item => item as IDeploymentItem).ToList() as IReadOnlyList, + result.Deployed.Select(item => item as IDeploymentItem).ToList() as IReadOnlyList, + failedModules.Select(item => item as IDeploymentItem).ToList() as IReadOnlyList, + deployInput.DryRun); + } + + return deployResult; + } + + static IReadOnlyList ToDeleteDeploymentItems(IReadOnlyList modules, bool dryRun) + { + var contents = new List(); + + foreach (var module in modules) + { + var deletedCloudCode = new DeletedCloudCode(module.Name.ToString(), module.Language.ToString()!, module.Path); + contents.Add(deletedCloudCode); + if (!dryRun) + { + deletedCloudCode.Status = new DeploymentStatus("Deployed", "Deleted remotely", SeverityLevel.Success); + deletedCloudCode.Progress = 100f; + } + } + + return contents; + } + + async Task CreateCloudCodeModuleFromSolution( + string solutionPath, + CancellationToken cancellationToken) + { + var success = true; + + var slnName = Path.GetFileNameWithoutExtension(solutionPath); + var dllOutputPath = Path.Combine(Path.GetTempPath(), slnName); + var moduleCompilationPath = Path.Combine(dllOutputPath, "module-compilation"); + + var ccmPath = dllOutputPath; + try + { + var moduleName = await m_SolutionPublisher.PublishToFolder( + solutionPath, + moduleCompilationPath, + cancellationToken); + ccmPath = await m_ModuleZipper.ZipCompilation(moduleCompilationPath, moduleName, cancellationToken); + } + catch (Exception) + { + success = false; + } + + return new ModuleGenerationResult(solutionPath, ccmPath, success); + } + + class ModuleGenerationResult + { + public string SolutionPath { get; } + public string CcmPath { get; } + public bool Success { get; } + + public ModuleGenerationResult(string solutionPath, string ccmPath, bool success) + { + SolutionPath = solutionPath; + CcmPath = ccmPath; + Success = success; + } + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Deploy/CloudCodeModulesLoader.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Deploy/CloudCodeModulesLoader.cs index e23da13..cd2a9da 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Deploy/CloudCodeModulesLoader.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Deploy/CloudCodeModulesLoader.cs @@ -1,6 +1,5 @@ using Unity.Services.Cli.Authoring.Model; using Unity.Services.CloudCode.Authoring.Editor.Core.Model; -using Language = Unity.Services.CloudCode.Authoring.Editor.Core.Model.Language; using Unity.Services.DeploymentApi.Editor; namespace Unity.Services.Cli.CloudCode.Deploy; diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Deploy/CloudCodePrecompiledModuleDeploymentService.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Deploy/CloudCodePrecompiledModuleDeploymentService.cs deleted file mode 100644 index aad7dd6..0000000 --- a/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Deploy/CloudCodePrecompiledModuleDeploymentService.cs +++ /dev/null @@ -1,118 +0,0 @@ -using Spectre.Console; -using Unity.Services.Cli.Authoring.Input; -using Unity.Services.Cli.Authoring.Model; -using Unity.Services.Cli.Authoring.Service; -using Unity.Services.Cli.CloudCode.Authoring; -using Unity.Services.Cli.CloudCode.Model; -using Unity.Services.Cli.CloudCode.Utils; -using Unity.Services.CloudCode.Authoring.Editor.Core.Deployment; -using Unity.Services.CloudCode.Authoring.Editor.Core.Model; -using Unity.Services.DeploymentApi.Editor; -using Unity.Services.Gateway.CloudCodeApiV1.Generated.Client; - -namespace Unity.Services.Cli.CloudCode.Deploy; - -class CloudCodePrecompiledModuleDeploymentService : IDeploymentService -{ - internal ICloudCodeModulesLoader CloudCodeModulesLoader { get; } - internal ICliEnvironmentProvider EnvironmentProvider { get; } - internal ICSharpClient CliCloudCodeClient { get; } - ICloudCodeDeploymentHandler CloudCodeDeploymentHandler { get; } - - readonly string m_ServiceType; - readonly string m_ServiceName; - readonly string m_DeployPrecompiledFileExtension; - - public CloudCodePrecompiledModuleDeploymentService( - ICloudCodeDeploymentHandler deployHandler, - ICloudCodeModulesLoader cloudCodeModulesLoader, - ICliEnvironmentProvider environmentProvider, - ICSharpClient client) - { - CloudCodeModulesLoader = cloudCodeModulesLoader; - EnvironmentProvider = environmentProvider; - CliCloudCodeClient = client; - CloudCodeDeploymentHandler = deployHandler; - m_ServiceType = CloudCodeConstants.ServiceTypeModules; - m_ServiceName = CloudCodeConstants.ServiceNameModule; - m_DeployPrecompiledFileExtension = ".ccm"; - } - - string IDeploymentService.ServiceType => m_ServiceType; - - string IDeploymentService.ServiceName => m_ServiceName; - - string IDeploymentService.DeployFileExtension => m_DeployPrecompiledFileExtension; - - public async Task Deploy( - DeployInput deployInput, - IReadOnlyList filePaths, - string projectId, - string environmentId, - StatusContext? loadingContext, - CancellationToken cancellationToken) - { - CliCloudCodeClient.Initialize(environmentId, projectId, cancellationToken); - EnvironmentProvider.Current = environmentId; - - loadingContext?.Status($"Reading {m_ServiceType}..."); - - var loadResult = await CloudCodeModulesLoader.LoadPrecompiledModulesAsync( - filePaths, - m_ServiceType); - - - loadingContext?.Status($"Deploying {m_ServiceType}..."); - - var dryrun = deployInput.DryRun; - var reconcile = deployInput.Reconcile; - DeployResult result = null!; - - try - { - result = await CloudCodeDeploymentHandler.DeployAsync(loadResult, reconcile, dryrun); - } - catch (ApiException) - { - /* - * Ignoring this because we already catch exceptions from UpdateScriptStatus() for each script and we don't - * want to stop execution when a script generates an exception. - */ - } - catch (DeploymentException ex) - { - result = ex.Result; - } - - if (result == null) - { - return new DeploymentResult(loadResult.OfType().ToList()); - } - - return new DeploymentResult( - result.Updated.Select(item => item as IDeploymentItem).ToList() as IReadOnlyList, - ToDeleteDeploymentItems(result.Deleted, deployInput.DryRun), - result.Created.Select(item => item as IDeploymentItem).ToList() as IReadOnlyList, - result.Deployed.Select(item => item as IDeploymentItem).ToList() as IReadOnlyList, - result.Failed.Select(item => item as IDeploymentItem).ToList() as IReadOnlyList, - dryrun); - } - - static IReadOnlyList ToDeleteDeploymentItems(IReadOnlyList modules, bool dryRun) - { - var contents = new List(); - - foreach (var module in modules) - { - var deletedCloudCode = new DeletedCloudCode(module.Name.ToString(), module.Language.ToString()!, module.Path); - contents.Add(deletedCloudCode); - if (!dryRun) - { - deletedCloudCode.Status = new DeploymentStatus("Deployed", "Deleted remotely", SeverityLevel.Success); - deletedCloudCode.Progress = 100f; - } - } - - return contents; - } -} diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Deploy/CloudCodeScript.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Deploy/CloudCodeScript.cs index ee785aa..375f2ed 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Deploy/CloudCodeScript.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Deploy/CloudCodeScript.cs @@ -29,7 +29,7 @@ public CloudCodeScript() { } public CloudCodeScript(string name, string path, float progress, DeploymentStatus? status) - : base(name, CloudCodeConstants.ServiceType, path, progress, status) + : base(name, CloudCodeConstants.ServiceTypeScripts, path, progress, status) { Name = ScriptName.FromPath(path); Language = LanguageType.JS; @@ -46,7 +46,7 @@ public CloudCodeScript( string body, List parameters, string lastPublishedDate) - : base(name.GetNameWithoutExtension(), CloudCodeConstants.ServiceType, path, 0F, DeploymentStatus.Empty) + : base(name.GetNameWithoutExtension(), CloudCodeConstants.ServiceTypeScripts, path, 0F, DeploymentStatus.Empty) { Name = name; Language = language; @@ -57,7 +57,7 @@ public CloudCodeScript( } public CloudCodeScript(GetScriptResponse response) - : base(response.Name, CloudCodeConstants.ServiceType, "", 0F, DeploymentStatus.Empty) + : base(response.Name, CloudCodeConstants.ServiceTypeScripts, "", 0F, DeploymentStatus.Empty) { Path = ""; Name = new ScriptName(response.Name); diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Deploy/CloudCodeScriptDeploymentService.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Deploy/CloudCodeScriptDeploymentService.cs index 6dea725..b170f87 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Deploy/CloudCodeScriptDeploymentService.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Deploy/CloudCodeScriptDeploymentService.cs @@ -41,14 +41,16 @@ public CloudCodeScriptDeploymentService( CliCloudCodeClient = cliCloudCodeClient; CloudCodeDeploymentHandler = deployHandlerWithOutput; - m_ServiceType = CloudCodeConstants.ServiceType; - m_ServiceName = CloudCodeConstants.ServiceName; - DeployFileExtension = CloudCodeConstants.JavaScriptFileExtension; + m_ServiceType = CloudCodeConstants.ServiceTypeScripts; + m_ServiceName = CloudCodeConstants.ServiceNameScripts; } - string IDeploymentService.ServiceType => m_ServiceType; - string IDeploymentService.ServiceName => m_ServiceName; - public string DeployFileExtension { get; } + public string ServiceType => m_ServiceType; + public string ServiceName => m_ServiceName; + public IReadOnlyList FileExtensions => new[] + { + CloudCodeConstants.FileExtensionJavaScript + }; public async Task Deploy( DeployInput deployInput, @@ -63,20 +65,30 @@ public async Task Deploy( loadingContext?.Status($"Reading {m_ServiceType} Scripts..."); - var loadResult = await CloudCodeScriptsLoader.LoadScriptsAsync( - filePaths, - m_ServiceType, - DeployFileExtension, - CloudCodeInputParser, - CloudCodeScriptParser, - cancellationToken); + List> loadTasks = new List>(); + foreach (var extension in FileExtensions) + { + loadTasks.Add( + CloudCodeScriptsLoader.LoadScriptsAsync( + filePaths, + m_ServiceType, + extension, + CloudCodeInputParser, + CloudCodeScriptParser, + cancellationToken)); + } + + await Task.WhenAll(); + + var loadedSuccessfullyScripts = loadTasks.SelectMany(task => task.Result.LoadedScripts).ToList(); + var loadedFailedScripts = loadTasks.SelectMany(task => task.Result.FailedContents).ToList(); loadingContext?.Status($"Deploying {m_ServiceType} Scripts..."); DeployResult result = null!; try { - result = await CloudCodeDeploymentHandler.DeployAsync(loadResult.LoadedScripts, deployInput.Reconcile, deployInput.DryRun); + result = await CloudCodeDeploymentHandler.DeployAsync(loadedSuccessfullyScripts, deployInput.Reconcile, deployInput.DryRun); } catch (ApiException) { @@ -94,14 +106,14 @@ public async Task Deploy( { var deployContent = new List(); - deployContent.AddRange(loadResult.LoadedScripts.OfType().ToList()); - deployContent.AddRange(loadResult.FailedContents.OfType().ToList()); + deployContent.AddRange(loadedSuccessfullyScripts.OfType().ToList()); + deployContent.AddRange(loadedSuccessfullyScripts.OfType().ToList()); return new DeploymentResult(deployContent); } var failedScripts = result.Failed.Select(item => item as IDeploymentItem) - .Concat(loadResult.FailedContents.Select(item => item as IDeploymentItem)) + .Concat(loadedFailedScripts.Select(item => item as IDeploymentItem)) .ToList() as IReadOnlyList; return new DeploymentResult( diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Handlers/ImportExport/Modules/CloudCodeModulesExporter.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Handlers/ImportExport/Modules/CloudCodeModulesExporter.cs index c7bb386..4a2b9ca 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Handlers/ImportExport/Modules/CloudCodeModulesExporter.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Handlers/ImportExport/Modules/CloudCodeModulesExporter.cs @@ -35,8 +35,8 @@ public CloudCodeModulesExporter( m_ModulesDownloader = cloudCodeModulesDownloader; } - protected override string FileName => CloudCodeConstants.ModulesZipName; - protected override string EntryName => CloudCodeConstants.ModulesEntryName; + protected override string FileName => CloudCodeConstants.ZipNameModules; + protected override string EntryName => CloudCodeConstants.EntryNameModules; protected override async Task> ListConfigsAsync(string projectId, string environmentId, CancellationToken cancellationToken) { @@ -44,7 +44,7 @@ protected override async Task> ListConfigsAsync(string proje projectId, environmentId, cancellationToken); var modules = moduleResults.Select(r => - new Module(new ScriptName(r.Name), Language.JS, $"{r.Name}{CloudCodeConstants.SingleModuleFileExtension}", r.SignedDownloadURL)); + new Module(new ScriptName(r.Name), Language.JS, $"{r.Name}{CloudCodeConstants.FileExtensionModulesCcm}", r.SignedDownloadURL)); return modules; } diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Handlers/ImportExport/Modules/CloudCodeModulesImporter.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Handlers/ImportExport/Modules/CloudCodeModulesImporter.cs index 9e8482c..1d4c692 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Handlers/ImportExport/Modules/CloudCodeModulesImporter.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Handlers/ImportExport/Modules/CloudCodeModulesImporter.cs @@ -29,8 +29,8 @@ public CloudCodeModulesImporter( m_CloudCodeService = cloudCodeService; } - protected override string EntryName => CloudCodeConstants.ModulesEntryName; - protected override string FileName => CloudCodeConstants.ModulesZipName; + protected override string EntryName => CloudCodeConstants.EntryNameModules; + protected override string FileName => CloudCodeConstants.ZipNameModules; protected override async Task DeleteConfigAsync( string projectId, @@ -64,7 +64,7 @@ protected override async Task> ListConfigsAsync( cloudProjectId, environmentId, cancellationToken); var modules = moduleResults.Select( - r => new Module(new ScriptName(r.Name), Language.JS, $"{r.Name}{CloudCodeConstants.SingleModuleFileExtension}", r.SignedDownloadURL)); + r => new Module(new ScriptName(r.Name), Language.JS, $"{r.Name}{CloudCodeConstants.FileExtensionModulesCcm}", r.SignedDownloadURL)); return modules; } diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Handlers/ImportExport/Scripts/CloudCodeScriptsExporter.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Handlers/ImportExport/Scripts/CloudCodeScriptsExporter.cs index f2b9703..fd6ae10 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Handlers/ImportExport/Scripts/CloudCodeScriptsExporter.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Handlers/ImportExport/Scripts/CloudCodeScriptsExporter.cs @@ -29,8 +29,8 @@ public CloudCodeScriptsExporter( m_CloudCodeService = cloudCodeService; } - protected override string FileName => CloudCodeConstants.JavascriptZipName; - protected override string EntryName => CloudCodeConstants.ScriptsEntryName; + protected override string FileName => CloudCodeConstants.ZipNameJavaScript; + protected override string EntryName => CloudCodeConstants.EntryNameScripts; protected override async Task> ListConfigsAsync(string projectId, string environmentId, CancellationToken cancellationToken) { var scriptResults = await m_CloudCodeService.ListAsync( diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Handlers/ImportExport/Scripts/CloudCodeScriptsImporter.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Handlers/ImportExport/Scripts/CloudCodeScriptsImporter.cs index fde0304..b274082 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Handlers/ImportExport/Scripts/CloudCodeScriptsImporter.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Handlers/ImportExport/Scripts/CloudCodeScriptsImporter.cs @@ -35,8 +35,8 @@ public CloudCodeScriptsImporter( m_CloudCodeService = cloudCodeService; } - protected override string FileName => CloudCodeConstants.JavascriptZipName; - protected override string EntryName => CloudCodeConstants.ScriptsEntryName; + protected override string FileName => CloudCodeConstants.ZipNameJavaScript; + protected override string EntryName => CloudCodeConstants.EntryNameScripts; protected override async Task DeleteConfigAsync( string projectId, diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Handlers/NewFile/NewFileModuleHandler.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Handlers/NewFile/NewFileModuleHandler.cs new file mode 100644 index 0000000..fd139d8 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Handlers/NewFile/NewFileModuleHandler.cs @@ -0,0 +1,63 @@ +using System.IO.Abstractions; +using Microsoft.Extensions.Logging; +using Unity.Services.Cli.CloudCode.Input; +using Unity.Services.Cli.Common.Console; +using Unity.Services.CloudCode.Authoring.Editor.Core.Solution; + +namespace Unity.Services.Cli.CloudCode.Handlers.NewFile; + +static class NewFileModuleHandler +{ + public static async Task CreateNewModule( + CloudCodeInput input, + IPath path, + IDirectory directory, + CloudCodeModuleSolutionGenerator solutionGenerator, + ILogger logger, + ILoadingIndicator loadingIndicator, + CancellationToken cancellationToken) + { + await loadingIndicator.StartLoadingAsync( + "Creating Solution", + _ => CreateNewModuleInternal(input, path, directory, solutionGenerator, logger, cancellationToken)); + } + + static async Task CreateNewModuleInternal( + CloudCodeInput input, + IPath path, + IDirectory directory, + CloudCodeModuleSolutionGenerator solutionGenerator, + ILogger logger, + CancellationToken cancellationToken) + { + var parentDirectory = input.ModuleDirectory ?? System.Environment.CurrentDirectory; + var moduleName = input.ModuleName ?? "NewCloudCodeModule"; + var moduleDirectory = path.Join(parentDirectory, moduleName); + + var directoryExists = directory.Exists(moduleDirectory); + + if (directoryExists && !input.UseForce) + { + logger.LogError($"A Cloud Code Module at path '{moduleDirectory}' already exists." + + " Add --force to overwrite the Cloud Code Module."); + } + else + { + if (directoryExists) + { + directory.Delete(moduleDirectory, true); + } + + try + { + await solutionGenerator.CreateSolutionWithProject(moduleDirectory, moduleName, cancellationToken); + logger.LogInformation($"Module '{moduleName}' created successfully at path '{moduleDirectory}'!", moduleName); + } + catch (Exception e) + { + logger.LogError(e.Message); + throw; + } + } + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudCode/IO/AssemblyLoader.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudCode/IO/AssemblyLoader.cs new file mode 100644 index 0000000..b279902 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudCode/IO/AssemblyLoader.cs @@ -0,0 +1,11 @@ +using System.Reflection; + +namespace Unity.Services.Cli.CloudCode.IO; + +class AssemblyLoader : IAssemblyLoader +{ + public Assembly Load(string assemblyString) + { + return Assembly.Load(assemblyString); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudCode/IO/CloudCodeFileStream.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudCode/IO/CloudCodeFileStream.cs new file mode 100644 index 0000000..dfb8152 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudCode/IO/CloudCodeFileStream.cs @@ -0,0 +1,18 @@ +using System.IO.Abstractions; +using Unity.Services.CloudCode.Authoring.Editor.Core.IO; + +namespace Unity.Services.Cli.CloudCode.IO; + +class CloudCodeFileStream : IFileStream +{ + internal FileSystemStream FileStream; + public CloudCodeFileStream(FileSystemStream fileStream) + { + FileStream = fileStream; + } + + public void Close() + { + FileStream.Close(); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudCode/IO/FileSystem.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudCode/IO/FileSystem.cs new file mode 100644 index 0000000..be77ee0 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudCode/IO/FileSystem.cs @@ -0,0 +1,99 @@ +using System.IO.Abstractions; +using System.IO.Compression; +using Unity.Services.CloudCode.Authoring.Editor.Core.IO; +using IFileSystem = Unity.Services.CloudCode.Authoring.Editor.Core.IO.IFileSystem; + +namespace Unity.Services.Cli.CloudCode.IO; + +class FileSystem : Common.IO.FileSystem, IFileSystem +{ + IFile m_File; + IPath m_Path; + IDirectory m_Directory; + + public FileSystem( + IFile file, + IPath path, + IDirectory directory) + { + m_File = file; + m_Path = path; + m_Directory = directory; + } + + public Task Copy(string sourceFileName, string destFileName, bool overwrite, CancellationToken token = default(CancellationToken)) + { + m_File.Copy(sourceFileName, destFileName, overwrite); + return Task.CompletedTask; + } + + public IFileStream CreateFile(string path) + { + return new CloudCodeFileStream(m_File.Create(path)); + } + + public void CreateZipFromDirectory(string sourceDirectoryName, string destinationArchiveFileName) + { + ZipFile.CreateFromDirectory(sourceDirectoryName, destinationArchiveFileName); + } + + public bool FileExists(string path) + { + return m_File.Exists(path); + } + + public bool DirectoryExists(string path) + { + return m_Directory.Exists(path); + } + + public string? GetDirectoryName(string path) + { + return m_Path.GetDirectoryName(path); + } + + public string GetFullPath(string path) + { + return m_Path.GetFullPath(path); + } + + public string GetFileNameWithoutExtension(string path) + { + return m_Path.GetFileNameWithoutExtension(path); + } + + public string Combine(params string[] paths) + { + return m_Path.Combine(paths); + } + + public string Join(string path1, string path2) + { + return m_Path.Join(path1, path2); + } + + public string ChangeExtension(string path, string extension) + { + return m_Path.ChangeExtension(path, extension); + } + + public string[] DirectoryGetFiles(string path, string searchPattern) + { + return Directory.GetFiles(path, searchPattern); + } + + public string[] DirectoryGetFiles(string path, string searchPattern, SearchOption searchOption) + { + return Directory.GetFiles(path, searchPattern, searchOption); + } + + public DirectoryInfo? DirectoryGetParent(string path) + { + return Directory.GetParent(path); + } + + public void FileMove(string sourceFileName, string destFileName) + { + File.Move(sourceFileName, destFileName); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudCode/IO/IAssemblyLoader.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudCode/IO/IAssemblyLoader.cs new file mode 100644 index 0000000..861827d --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudCode/IO/IAssemblyLoader.cs @@ -0,0 +1,8 @@ +using System.Reflection; + +namespace Unity.Services.Cli.CloudCode.IO; + +interface IAssemblyLoader +{ + Assembly Load(string assemblyString); +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Input/CloudCodeInput.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Input/CloudCodeInput.cs index 2aee86a..f63fa49 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Input/CloudCodeInput.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Input/CloudCodeInput.cs @@ -33,6 +33,9 @@ public class CloudCodeInput : DeployInput public static readonly Argument ModuleNameArgument = new("module-name", "Name of the target module"); + public static readonly Argument ModuleDirectoryArgument = + new("module-directory", "Directory for the new module"); + [InputBinding(nameof(ScriptNameArgument))] public string? ScriptName { get; set; } @@ -50,4 +53,7 @@ public class CloudCodeInput : DeployInput [InputBinding(nameof(ModuleNameArgument))] public string? ModuleName { get; set; } + + [InputBinding(nameof(ModuleDirectoryArgument))] + public string? ModuleDirectory { get; set; } } diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Service/CloudCodeInputParser.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Service/CloudCodeInputParser.cs index 0fca18a..b66610b 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Service/CloudCodeInputParser.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Service/CloudCodeInputParser.cs @@ -81,9 +81,21 @@ public async Task LoadScriptCodeAsync(string filePath, CancellationToken } catch (UnauthorizedAccessException exception) { - throw new CliException(string.Join(" ", exception.Message, - "Make sure that the CLI has the permissions to access the file and that the " + - "specified path points to a file and not a directory."), ExitCode.HandledError); + throw new CliException( + string.Join( + " ", + exception.Message, + $"The path passed is not a valid file path, please review it and try again."), + ExitCode.HandledError); + } + catch (IOException exception) + { + throw new CliException( + string.Join( + " ", + exception.Message, + $"The file path passed could not be found or is incomplete."), + ExitCode.HandledError); } } diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Solution/FileContentRetriever.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Solution/FileContentRetriever.cs new file mode 100644 index 0000000..8121a01 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Solution/FileContentRetriever.cs @@ -0,0 +1,29 @@ +using Unity.Services.Cli.CloudCode.IO; +using Unity.Services.CloudCode.Authoring.Editor.Core.Solution; + +namespace Unity.Services.Cli.CloudCode.Solution; + +class FileContentRetriever : IFileContentRetriever +{ + internal const string AssemblyString = "Unity.Services.CloudCode.Authoring.Editor.Core"; + + IAssemblyLoader m_AssemblyLoader; + public FileContentRetriever(IAssemblyLoader assemblyLoader) + { + m_AssemblyLoader = assemblyLoader; + } + + public Task GetFileContent(string path, CancellationToken token = default) + { + var assembly = m_AssemblyLoader.Load(AssemblyString); + var stream = assembly.GetManifestResourceStream(path); + + if (stream == null) + { + throw new FileLoadException($"Could not load file at path '{path}' from assembly '{assembly.FullName}'"); + } + + var streamReader = new StreamReader(stream); + return streamReader.ReadToEndAsync(); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Solution/TemplateInfo.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Solution/TemplateInfo.cs new file mode 100644 index 0000000..5279acf --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Solution/TemplateInfo.cs @@ -0,0 +1,12 @@ +using Unity.Services.CloudCode.Authoring.Editor.Core.Solution; + +namespace Unity.Services.Cli.CloudCode.Solution; + +class TemplateInfo : ITemplateInfo +{ + public string PathSolution => @"Unity.Services.CloudCode.Authoring.Editor.Core.Solution.sln"; + public string PathProject => @"Unity.Services.CloudCode.Authoring.Editor.Core.Project.csproj"; + public string PathExampleClass => @"Unity.Services.CloudCode.Authoring.Editor.Core.Example.cs"; + public string PathConfig => @"Unity.Services.CloudCode.Authoring.Editor.Core.FolderProfile.pubxml"; + public string PathConfigUser => @"Unity.Services.CloudCode.Authoring.Editor.Core.FolderProfile.pubxml.user"; +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Unity.Services.Cli.CloudCode.csproj b/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Unity.Services.Cli.CloudCode.csproj index 0c19546..ca126f6 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Unity.Services.Cli.CloudCode.csproj +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Unity.Services.Cli.CloudCode.csproj @@ -21,7 +21,7 @@ - + diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Utils/CloudCodeCliDotnetRunner.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Utils/CloudCodeCliDotnetRunner.cs new file mode 100644 index 0000000..c7ddf69 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Utils/CloudCodeCliDotnetRunner.cs @@ -0,0 +1,51 @@ +using Unity.Services.Cli.Common.Process; +using Unity.Services.CloudCode.Authoring.Editor.Core.Dotnet; + +namespace Unity.Services.Cli.CloudCode.Utils; + +class CloudCodeCliDotnetRunner : IDotnetRunner +{ + ICliProcess m_CliProcess; + + public CloudCodeCliDotnetRunner(ICliProcess cliProcess) + { + m_CliProcess = cliProcess; + } + + public async Task IsDotnetAvailable() + { + try + { + await m_CliProcess.ExecuteAsync( + "dotnet", + System.Environment.CurrentDirectory, + new[] + { + "--version" + }, + CancellationToken.None); + } + catch (ProcessException) + { + return false; + } + + return true; + } + + public async Task ExecuteDotnetAsync(IEnumerable arguments, CancellationToken cancellationToken) + { + try + { + return await m_CliProcess.ExecuteAsync( + "dotnet", + System.Environment.CurrentDirectory, + arguments.ToArray(), + cancellationToken); + } + catch (ProcessException e) + { + throw new DotnetCommandFailedException(e.Message); + } + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Utils/CloudCodeConstants.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Utils/CloudCodeConstants.cs index 0a5cab8..5f07a21 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Utils/CloudCodeConstants.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Utils/CloudCodeConstants.cs @@ -4,17 +4,18 @@ namespace Unity.Services.Cli.CloudCode.Utils; static class CloudCodeConstants { - public const string ServiceType = "Cloud Code Scripts"; + public const string ServiceTypeScripts = "Cloud Code Scripts"; public const string ServiceTypeModules = "Cloud Code Modules"; - public const string JavaScriptFileExtension = ".js"; - public const string JavascriptZipName = "ugs-cc-scripts.jszip"; - public const string SingleModuleFileExtension = ".ccm"; - public const string ModulesZipName = "ugs.ccmzip"; + public const string FileExtensionJavaScript = ".js"; + public const string FileExtensionModulesCcm = ".ccm"; + public const string FileExtensionModulesSln = ".sln"; + public const string ZipNameJavaScript = "ugs-cc-scripts.jszip"; + public const string ZipNameModules = "ugs.ccmzip"; internal static readonly string EntryName = $"__ugs-cli_{TelemetryConfigurationProvider.GetCliVersion()}"; - internal static readonly string ModulesEntryName = "cloud-code modules"; - internal static readonly string ScriptsEntryName = "cloud-code scripts"; + internal static readonly string EntryNameScripts = "cloud-code scripts"; + internal static readonly string EntryNameModules = "cloud-code modules"; - internal static readonly string ServiceName = "cloud-code-scripts"; - internal static readonly string ServiceNameModule = "cloud-code-modules"; + internal static readonly string ServiceNameScripts = "cloud-code-scripts"; + internal static readonly string ServiceNameModules = "cloud-code-modules"; } diff --git a/Unity.Services.Cli/Unity.Services.Cli.Common/Exceptions/ExceptionHelper.cs b/Unity.Services.Cli/Unity.Services.Cli.Common/Exceptions/ExceptionHelper.cs index 3a85d39..96e4d14 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.Common/Exceptions/ExceptionHelper.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.Common/Exceptions/ExceptionHelper.cs @@ -13,6 +13,7 @@ using PlayerAdminApiException = Unity.Services.Gateway.PlayerAdminApiV3.Generated.Client.ApiException; using PlayerAuthException = Unity.Services.Gateway.PlayerAuthApiV1.Generated.Client.ApiException; using HostingApiException = Unity.Services.Gateway.GameServerHostingApiV1.Generated.Client.ApiException; +using SentisApiException = Unity.Services.Gateway.SentisApiV1.Generated.Client.ApiException; namespace Unity.Services.Cli.Common.Exceptions; @@ -82,6 +83,9 @@ public int HandleException(Exception exception, ILogger logger, InvocationContex case PlayerAuthException playerAuthApiException: HandleApiException(exception, logger, playerAuthApiException.ErrorCode); break; + case SentisApiException sentiApiException: + HandleApiException(exception, logger, sentiApiException.ErrorCode); + break; case AggregateException aggregateException: foreach (var ex in aggregateException.InnerExceptions) { diff --git a/Unity.Services.Cli/Unity.Services.Cli.Common/IO/FileSystem.cs b/Unity.Services.Cli/Unity.Services.Cli.Common/IO/FileSystem.cs new file mode 100644 index 0000000..2c927aa --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Common/IO/FileSystem.cs @@ -0,0 +1,38 @@ +namespace Unity.Services.Cli.Common.IO; + +public class FileSystem +{ + public Task ReadAllText( + string path, + CancellationToken token = new CancellationToken()) + { + return File.ReadAllTextAsync(path, token); + } + + public Task WriteAllText( + string path, + string contents, + CancellationToken token = new CancellationToken()) + { + return File.WriteAllTextAsync(path, contents, token); + } + + public Task Delete(string path, + CancellationToken token = default(CancellationToken)) + { + File.Delete(path); + return Task.CompletedTask; + } + + public Task CreateDirectory(string path) + { + var directoryInfo = Directory.CreateDirectory(path); + return Task.FromResult(directoryInfo); + } + + public Task DeleteDirectory(string path, bool recursive) + { + Directory.Delete(path, recursive); + return Task.CompletedTask; + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Common/Networking/Endpoints/NetworkTargetEndpoints.cs b/Unity.Services.Cli/Unity.Services.Cli.Common/Networking/Endpoints/NetworkTargetEndpoints.cs index abe0011..e69c4da 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.Common/Networking/Endpoints/NetworkTargetEndpoints.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.Common/Networking/Endpoints/NetworkTargetEndpoints.cs @@ -20,7 +20,6 @@ public abstract class NetworkTargetEndpoints /// protected abstract string Staging { get; } - /// /// URL to use when targeting mock server. /// diff --git a/Unity.Services.Cli/Unity.Services.Cli.Common/Networking/Endpoints/TriggersEndpoints.cs b/Unity.Services.Cli/Unity.Services.Cli.Common/Networking/Endpoints/TriggersEndpoints.cs new file mode 100644 index 0000000..eb9cdca --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Common/Networking/Endpoints/TriggersEndpoints.cs @@ -0,0 +1,8 @@ +namespace Unity.Services.Cli.Common.Networking; + +public class TriggersEndpoints : NetworkTargetEndpoints +{ + protected override string Prod { get; } = "https://services.api.unity.com"; + + protected override string Staging { get; } = "https://staging.services.api.unity.com"; +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Common/Unity.Services.Cli.Common.csproj b/Unity.Services.Cli/Unity.Services.Cli.Common/Unity.Services.Cli.Common.csproj index 4c33ff2..fa0b0ff 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.Common/Unity.Services.Cli.Common.csproj +++ b/Unity.Services.Cli/Unity.Services.Cli.Common/Unity.Services.Cli.Common.csproj @@ -21,9 +21,10 @@ + - + diff --git a/Unity.Services.Cli/Unity.Services.Cli.Economy.UnitTest/Authoring/Deploy/EconomyDeploymentServiceTests.cs b/Unity.Services.Cli/Unity.Services.Cli.Economy.UnitTest/Authoring/Deploy/EconomyDeploymentServiceTests.cs new file mode 100644 index 0000000..6d35155 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Economy.UnitTest/Authoring/Deploy/EconomyDeploymentServiceTests.cs @@ -0,0 +1,260 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Moq; +using NUnit.Framework; +using Unity.Services.Cli.Authoring.Input; +using Unity.Services.Cli.Authoring.Model; +using Unity.Services.Cli.Economy.Authoring; +using Unity.Services.Cli.Economy.Authoring.Deploy; +using Unity.Services.Economy.Editor.Authoring.Core.Deploy; +using Unity.Services.Economy.Editor.Authoring.Core.Model; +using Unity.Services.Gateway.EconomyApiV2.Generated.Client; +using Statuses = Unity.Services.Cli.Authoring.Model.Statuses; + +namespace Unity.Services.Cli.Economy.UnitTest.Authoring.Deploy; + +public class EconomyDeploymentServiceTests +{ + + const string k_ValidProjectId = "00000000-0000-0000-0000-000000000000"; + const string k_ValidEnvironmentId = "00000000-0000-0000-0000-000000000000"; + + static readonly List k_ValidFilePaths = new() + { + "test_1.ec", + "test_2.ec", + "test_3.ec", + "test_4.ec" + }; + + DeployInput m_DefaultInput = new() + { + CloudProjectId = k_ValidProjectId, + Reconcile = false + }; + + readonly Mock m_MockCliEconomyClient = new(); + readonly Mock m_MockEconomyResourcesLoader = new(); + readonly Mock m_MockEconomyDeploymentHandler = new(); + readonly Mock m_MockLogger = new(); + EconomyDeploymentService m_EconomyDeploymentService; + + public EconomyDeploymentServiceTests() + { + m_EconomyDeploymentService = new( + m_MockCliEconomyClient.Object, + m_MockEconomyResourcesLoader.Object, + m_MockEconomyDeploymentHandler.Object + ); + } + + static EconomyCurrency s_CreatedEconResource = new EconomyCurrency("TEST_ID_1") + { Name = "TEST_ID_1" }; + static EconomyCurrency s_UpdatedEconResource = new EconomyCurrency("TEST_ID_2") + { Name = "TEST_ID_2" }; + static EconomyCurrency s_DeletedEconResource = new EconomyCurrency("TEST_ID_3") + { Name = "TEST_ID_3" }; + static EconomyCurrency s_FailedToReadEconResource = new EconomyCurrency("TEST_ID_4") + { Name = "TEST_ID_4" }; + + + static List s_DeployContents = new() + { + new(s_CreatedEconResource.Id, "", s_CreatedEconResource.Path, 0, + Statuses.Loaded), + new(s_UpdatedEconResource.Id, "", s_UpdatedEconResource.Path, 0, + Statuses.Loaded), + new(s_DeletedEconResource.Id, "", s_DeletedEconResource.Path, 0, + Statuses.Loaded), + new(s_FailedToReadEconResource.Id, "", s_FailedToReadEconResource.Path, 0, + Statuses.FailedToRead), + }; + + static List s_ResourcesList = new List() + { + s_CreatedEconResource, + s_UpdatedEconResource, + s_DeletedEconResource, + s_FailedToReadEconResource + }; + + + static DeployResult s_DeployResult = new() + { + Created = new List() { s_CreatedEconResource }, + Updated = new List() { s_UpdatedEconResource }, + Deleted = new List() { s_DeletedEconResource }, + Deployed = new List() { s_CreatedEconResource, s_UpdatedEconResource }, + Failed = new List() { s_FailedToReadEconResource } + }; + + [SetUp] + public void SetUp() + { + m_MockCliEconomyClient.Reset(); + m_MockEconomyResourcesLoader.Reset(); + m_MockEconomyDeploymentHandler.Reset(); + m_MockLogger.Reset(); + + for (int i = 0; i < k_ValidFilePaths.Count; i++) + { + m_MockEconomyResourcesLoader.Setup(d => + d.LoadResourceAsync(k_ValidFilePaths[i], It.IsAny())) + .ReturnsAsync(s_ResourcesList[i]); + } + + + m_MockEconomyDeploymentHandler.Setup(x => + x.DeployAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(s_DeployResult); + } + + [Test] + public async Task DeployAsync_CallsInitializeCorrectly() + { + await m_EconomyDeploymentService.Deploy( + m_DefaultInput, + k_ValidFilePaths, + k_ValidProjectId, + k_ValidEnvironmentId, + null!, + CancellationToken.None); + + m_MockCliEconomyClient.Verify( + x => x.Initialize( + k_ValidProjectId, + k_ValidEnvironmentId, + CancellationToken.None), + Times.Once); + } + + [Test] + public async Task DeployAsync_CallsLoadScriptsAsyncCorrectly() + { + await m_EconomyDeploymentService.Deploy( + m_DefaultInput, + k_ValidFilePaths, + k_ValidProjectId, + k_ValidEnvironmentId, + null!, + CancellationToken.None); + + m_MockEconomyResourcesLoader.Verify(x => + x.LoadResourceAsync( + k_ValidFilePaths[0], + CancellationToken.None), + Times.Once); + } + + [Test] + public async Task DeployAsync_CallsDeployAsyncCorrectly() + { + await m_EconomyDeploymentService.Deploy( + m_DefaultInput, + k_ValidFilePaths, + k_ValidProjectId, + k_ValidEnvironmentId, + null!, + CancellationToken.None); + + m_MockEconomyDeploymentHandler.Verify(x => + x.DeployAsync( + s_ResourcesList, + m_DefaultInput.DryRun, + m_DefaultInput.Reconcile, + CancellationToken.None), + Times.Once); + } + + [Test] + public void DeployAsync_DoesNotThrowOnApiException() + { + m_MockEconomyDeploymentHandler.Setup(ex => ex + .DeployAsync( + It.IsAny>(), + false, + false, + It.IsAny()) + ) + .ThrowsAsync(new ApiException()); + + Assert.DoesNotThrowAsync(() => + m_EconomyDeploymentService.Deploy( + m_DefaultInput, + k_ValidFilePaths, + k_ValidProjectId, + k_ValidEnvironmentId, + null!, + CancellationToken.None) + ); + } + + [Test] + public async Task DeployAsync_ReturnsCorrectResultForUpdatedCreatedAndDeleted() + { + var deploymentResult = await m_EconomyDeploymentService.Deploy( + m_DefaultInput, + k_ValidFilePaths, + k_ValidProjectId, + k_ValidEnvironmentId, + null!, + CancellationToken.None); + + Assert.Multiple(() => + { + Assert.That(deploymentResult.Created.First().Name, Is.EqualTo(s_DeployResult.Created.First().Id)); + Assert.That(deploymentResult.Updated.First().Name, Is.EqualTo(s_DeployResult.Updated.First().Id)); + Assert.That(deploymentResult.Deleted.First().Name, Is.EqualTo(s_DeployResult.Deleted.First().Id)); + }); + } + + [Test] + public async Task DeployAsync_ReturnsCorrectResultForDeployed() + { + var deploymentResult = await m_EconomyDeploymentService.Deploy( + m_DefaultInput, + k_ValidFilePaths, + k_ValidProjectId, + k_ValidEnvironmentId, + null!, + CancellationToken.None); + + Assert.That(deploymentResult.Deployed.Count, Is.EqualTo(s_DeployResult.Deployed.Count)); + foreach (var deployContent in deploymentResult.Deployed) + { + Assert.NotNull( + s_DeployResult.Deployed.Find(x => + string.Equals(x.Id, deployContent.Name))); + } + } + + [Test] + public async Task DeployAsync_ReturnsCorrectResultForFailed() + { + var deploymentResult = await m_EconomyDeploymentService.Deploy( + m_DefaultInput, + k_ValidFilePaths, + k_ValidProjectId, + k_ValidEnvironmentId, + null!, + CancellationToken.None); + + var failedContent = s_DeployContents.ToList() + .FindAll(x => x.Status.Message == Statuses.FailedToRead); + + Assert.That(deploymentResult.Failed.Count, Is.EqualTo(failedContent.Count)); + foreach (var deployContent in deploymentResult.Failed) + { + Assert.NotNull( + failedContent.Find(x => + x.Name == deployContent.Name)); + } + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Economy.UnitTest/Authoring/EconomyClientTests.cs b/Unity.Services.Cli/Unity.Services.Cli.Economy.UnitTest/Authoring/EconomyClientTests.cs new file mode 100644 index 0000000..dee0e87 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Economy.UnitTest/Authoring/EconomyClientTests.cs @@ -0,0 +1,272 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using NUnit.Framework; +using Unity.Services.Cli.Economy.Authoring; +using Unity.Services.Cli.Economy.Handlers; +using Unity.Services.Cli.Economy.Service; +using Unity.Services.Economy.Editor.Authoring.Core.IO; +using Unity.Services.Economy.Editor.Authoring.Core.Model; +using Unity.Services.Gateway.EconomyApiV2.Generated.Model; + +namespace Unity.Services.Cli.Economy.UnitTest.Authoring; + +[TestFixture] +public class EconomyClientTests +{ + const string k_TestProjectId = "00000000-0000-0000-0000-000000000000"; + const string k_TestEnvironmentId = "00000000-0000-0000-0000-000000000000"; + + readonly Mock m_MockEconomyService = new Mock(); + readonly Mock m_MockFileSystem = new Mock(); + + readonly EconomyResource m_TestEconomyResource; + readonly EconomyClient m_EconomyClient; + + readonly CurrencyItemResponse m_TestCurrencyItemResponse = new CurrencyItemResponse( + "TEST_ID", + "TEST_NAME", + CurrencyItemResponse.TypeEnum.CURRENCY, + 0, + 100, + "custom data", + new ModifiedMetadata(DateTime.Now), + new ModifiedMetadata(DateTime.Now)); + + readonly InventoryItemResponse m_TestInventoryItemResponse = new InventoryItemResponse( + "TEST_ID", + "TEST_NAME", + InventoryItemResponse.TypeEnum.INVENTORYITEM, + "custom data", + new ModifiedMetadata(DateTime.Now), + new ModifiedMetadata(DateTime.Now)); + + readonly VirtualPurchaseResourceResponse m_TestVirtualPurchaseResponse = new VirtualPurchaseResourceResponse( + "TEST_ID", + "TEST_NAME", + VirtualPurchaseResourceResponse.TypeEnum.VIRTUALPURCHASE, + new List(), + new List(), + "custom data", + new ModifiedMetadata(DateTime.Now), + new ModifiedMetadata(DateTime.Now)); + + readonly RealMoneyPurchaseResourceResponse m_TestRealMoneyPurchaseResponse = new RealMoneyPurchaseResourceResponse( + "TEST_ID", + "TEST_NAME", + RealMoneyPurchaseResourceResponse.TypeEnum.MONEYPURCHASE, + new RealMoneyPurchaseItemResponseStoreIdentifiers(), + new List(), + "custom data", + new ModifiedMetadata(DateTime.Now), + new ModifiedMetadata(DateTime.Now)); + + public EconomyClientTests() + { + m_EconomyClient = new EconomyClient( + m_MockEconomyService.Object, + k_TestProjectId, + k_TestEnvironmentId, + CancellationToken.None); + + m_TestEconomyResource = new EconomyCurrency(m_TestCurrencyItemResponse.Id) + { + Name = m_TestCurrencyItemResponse.Name + }; + } + + [SetUp] + public void SetUp() + { + m_MockEconomyService.Reset(); + m_MockFileSystem.Reset(); + + m_MockFileSystem + .Setup(x => + x.ReadAllText(It.IsAny(), It.IsAny())) + .ReturnsAsync(m_TestCurrencyItemResponse.ToJson); + } + + [Test] + public void ConstructorInitializeProperties() + { + CancellationToken cancellationToken = new(true); + var economyClient = new EconomyClient( + m_MockEconomyService.Object, + "k_TestProjectId", + "k_TestEnvironmentId", + cancellationToken); + + Assert.Multiple(() => + { + Assert.That(economyClient.ProjectId, Is.EqualTo("k_TestProjectId")); + Assert.That(economyClient.EnvironmentId, Is.EqualTo("k_TestEnvironmentId")); + Assert.That(economyClient.CancellationToken, Is.EqualTo(cancellationToken)); + }); + } + + [Test] + public void InitializeChangeProperties() + { + CancellationToken cancellationToken = new(true); + m_EconomyClient.Initialize(k_TestEnvironmentId, k_TestProjectId, cancellationToken); + Assert.Multiple(() => + { + Assert.That(m_EconomyClient.ProjectId, Is.SameAs(k_TestProjectId)); + Assert.That(m_EconomyClient.EnvironmentId, Is.SameAs(k_TestEnvironmentId)); + Assert.That(m_EconomyClient.CancellationToken, Is.EqualTo(cancellationToken)); + }); + } + + [Test] + public async Task Edit_CallsEditAsyncCorrectly() + { + var expectedResource = + EconomyConfigurationBuilder.ConstructAddConfigResourceRequest(m_TestEconomyResource); + + await m_EconomyClient.Update(m_TestEconomyResource); + + m_MockEconomyService.Verify(s => + s.EditAsync( + m_TestEconomyResource.Id, + expectedResource!, + k_TestProjectId, + k_TestEnvironmentId, + CancellationToken.None), + Times.Once); + } + + [Test] + public async Task Add_CallsAddAsyncCorrectly() + { + var expectedResource = + EconomyConfigurationBuilder.ConstructAddConfigResourceRequest(m_TestEconomyResource); + + await m_EconomyClient.Create(m_TestEconomyResource); + + m_MockEconomyService.Verify(s => + s.AddAsync( + expectedResource!, + k_TestProjectId, + k_TestEnvironmentId, + CancellationToken.None), + Times.Once); + } + + [Test] + public async Task Delete_CallsDeleteAsyncCorrectly() + { + var testId = "TEST_ID"; + await m_EconomyClient.Delete(testId); + + m_MockEconomyService.Verify(s => + s.DeleteAsync( + testId, + k_TestProjectId, + k_TestEnvironmentId, + CancellationToken.None), + Times.Once); + } + + [Test] + public async Task Publish_CallsPublishAsyncCorrectly() + { + await m_EconomyClient.Publish(); + + m_MockEconomyService.Verify(s => + s.PublishAsync( + k_TestProjectId, + k_TestEnvironmentId, + It.IsAny()), + Times.Once); + } + + [Test] + public async Task GetResources_CallsGetResourcesAsyncCorrectly() + { + GetResourcesResponseResultsInner getResResultsInner = new(m_TestCurrencyItemResponse); + + m_MockEconomyService + .Setup(x => + x.GetResourcesAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync( + new List + { + getResResultsInner + }); + + var resources = await m_EconomyClient.List(); + + m_MockEconomyService.Verify(s => + s.GetResourcesAsync( + k_TestProjectId, + k_TestEnvironmentId, + CancellationToken.None), + Times.Once); + var expectedResource = new EconomyCurrency(m_TestCurrencyItemResponse.Id) + { + Name = m_TestCurrencyItemResponse.Name + }; + + Assert.That(resources.Count, Is.EqualTo(1)); + Assert.That(resources[0], Is.EqualTo(expectedResource)); + } + + [Test] + public async Task ListResources_CallConstructResourcesAsyncCorrectly() + { + var responses = new List() + { + new GetResourcesResponseResultsInner(m_TestCurrencyItemResponse), + new GetResourcesResponseResultsInner(m_TestInventoryItemResponse), + new GetResourcesResponseResultsInner(m_TestVirtualPurchaseResponse), + new GetResourcesResponseResultsInner(m_TestRealMoneyPurchaseResponse) + }; + + m_MockEconomyService + .Setup(x => + x.GetResourcesAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(responses); + + var resources = await m_EconomyClient.List(); + + var expectedResources = new List() + { + new EconomyCurrency(m_TestCurrencyItemResponse.Id) + { + Name = m_TestCurrencyItemResponse.Name + }, + new EconomyInventoryItem(m_TestInventoryItemResponse.Id) + { + Name = m_TestCurrencyItemResponse.Name + }, + new EconomyVirtualPurchase(m_TestVirtualPurchaseResponse.Id) + { + Name = m_TestVirtualPurchaseResponse.Name + }, + new EconomyRealMoneyPurchase(m_TestRealMoneyPurchaseResponse.Id) + { + Name = m_TestRealMoneyPurchaseResponse.Name + } + }; + + Assert.That(resources.Count, Is.EqualTo(4)); + Assert.Multiple( + () => + { + for (int i = 0; i < resources.Count; i++) + { + Assert.That(resources[i], Is.TypeOf(expectedResources[i].GetType())); + Assert.That(resources[i], Is.EqualTo(expectedResources[i])); + } + }); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Economy.UnitTest/Authoring/EconomyResourcesLoaderTests.cs b/Unity.Services.Cli/Unity.Services.Cli.Economy.UnitTest/Authoring/EconomyResourcesLoaderTests.cs new file mode 100644 index 0000000..eaa56f1 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Economy.UnitTest/Authoring/EconomyResourcesLoaderTests.cs @@ -0,0 +1,466 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using Unity.Services.Cli.Economy.Authoring; +using Unity.Services.Cli.Economy.Authoring.IO; +using Unity.Services.Cli.Economy.Model; +using Unity.Services.Cli.Economy.Templates; +using Unity.Services.Economy.Editor.Authoring.Core.IO; +using Unity.Services.Economy.Editor.Authoring.Core.Model; +using Unity.Services.Gateway.EconomyApiV2.Generated.Model; +using Statuses = Unity.Services.Cli.Authoring.Model.Statuses; + +namespace Unity.Services.Cli.Economy.UnitTest.Authoring; + +public class EconomyResourcesLoaderTests +{ + Mock? m_MockFileSystem; + Mock? m_MockEconomyJsonConverter; + EconomyResourcesLoader? m_EconomyResourcesLoader; + + CurrencyItemResponse m_TestCurrencyItemResponse = new( + "TEST_ID", + "TEST_NAME", + CurrencyItemResponse.TypeEnum.CURRENCY, + 0, + 100, + "custom data", + new ModifiedMetadata(DateTime.Now), + new ModifiedMetadata(DateTime.Now) + ); + + IEnumerable? m_ConfigValidationTestCases; + IEnumerable? m_EmptyResourceTestCases; + IEnumerable<(EconomyResourceFile file, string path)>? m_LoadFileTestCases; + + [SetUp] + public void SetUp() + { + m_MockFileSystem = new Mock(); + m_MockEconomyJsonConverter = new Mock(); + + m_MockFileSystem! + .Setup(x => + x.ReadAllText(It.IsAny(), It.IsAny())) + .ReturnsAsync(m_TestCurrencyItemResponse.ToJson); + + m_EconomyResourcesLoader = new(m_MockEconomyJsonConverter.Object, m_MockFileSystem.Object); + + m_ConfigValidationTestCases = new[] + { + new Resource("", "", EconomyResourceTypes.InventoryItem, ""), + new Resource("", "", EconomyResourceTypes.Currency, ""), + new Resource("", "", EconomyResourceTypes.VirtualPurchase, ""), + new Resource("", "", EconomyResourceTypes.MoneyPurchase, "") + }; + + m_EmptyResourceTestCases = new IEconomyResource[] + { + new EconomyCurrency("Currency"), + new EconomyInventoryItem("Inventory Item"), + new EconomyVirtualPurchase("Virtual Purchase") + { + Costs = new []{new Cost()}, + Rewards = new []{new Reward()} + }, + new EconomyRealMoneyPurchase("Real Money Purchase") + { + Rewards = new []{new RealMoneyReward()}, + StoreIdentifiers = new () + } + }; + + m_LoadFileTestCases = new (EconomyResourceFile file, string path)[] + { + ( + new EconomyCurrencyFile + { + Id = "Currency", + Initial = 0, + Max = 100, + Name = "Currency" + }, + EconomyResourcesExtensions.Currency + ), + ( + new EconomyInventoryItemFile + { + Id = "Inventory_Item", + Name = "Inventory Item" + }, + EconomyResourcesExtensions.InventoryItem + ), + ( + new EconomyVirtualPurchaseFile() + { + Id = "Virtual_Purchase", + Name = "Virtual Purchase", + Costs = new [] + { + new Cost + { + ResourceId = "Currency", + Amount = 1 + } + }, + Rewards = new [] { + new Reward + { + ResourceId = "Inventory_Item", + Amount = 1 + } + } + }, + EconomyResourcesExtensions.VirtualPurchase + ), + ( + new EconomyRealMoneyPurchaseFile() + { + Id = "Real_Money_Purchase", + Name = "Real Money Purchase", + Rewards = new [] + { + new RealMoneyReward + { + ResourceId = "Inventory_Item", + Amount = 1 + } + } + }, + EconomyResourcesExtensions.MoneyPurchase + ) + }; + } + + [Test] + public async Task LoadResourcesAsync_FailsToReadFile() + { + m_MockFileSystem! + .Setup(x => x.ReadAllText(It.IsAny(), It.IsAny())) + .ThrowsAsync(new FileNotFoundException()); + + var deployContents = await m_EconomyResourcesLoader!.LoadResourceAsync( + "", + CancellationToken.None); + + Assert.That(deployContents.Status.Message, Is.SameAs(Statuses.FailedToRead)); + } + + [Test] + public async Task LoadResourcesAsync_FailsToDeserialize() + { + m_MockEconomyJsonConverter! + .Setup(x => x.DeserializeObject(It.IsAny())) + .Throws(new JsonReaderException()); + + var deployContents = await m_EconomyResourcesLoader!.LoadResourceAsync( + "", + CancellationToken.None); + + Assert.That(deployContents.Status.Message, Is.SameAs(Statuses.FailedToRead)); + } + + [Test] + public async Task LoadResourcesAsync_ReturnsCorrectResource() + { + string filepath = "test.ecc"; + + var file = new EconomyCurrencyFile() + { + Id = "TEST_ID", + Name = "TEST_NAME", + Type = EconomyResourceTypes.Currency, + CustomData = m_TestCurrencyItemResponse.ToJson(), + Initial = 0, + Max = 100, + }; + + m_MockEconomyJsonConverter! + .Setup(x => x.DeserializeObject(It.IsAny())) + .Returns(JsonConvert.DeserializeObject(JsonConvert.SerializeObject(file))); + + + var resource = await m_EconomyResourcesLoader!.LoadResourceAsync( + filepath, + CancellationToken.None); + + Assert.That(resource.Status.Message, Is.SameAs(Statuses.Loaded)); + Assert.Multiple(() => + { + Assert.That(resource.Id, Is.EqualTo(file.Id)); + Assert.That(((EconomyResource)resource).EconomyType, Is.EqualTo(file.Type)); + Assert.That(resource.Name, Is.EqualTo(file.Name)); + Assert.That(resource.Path, Is.EqualTo(filepath)); + }); + } + + [Test] + public void ConstructResourceFile_ReturnsCorrectResourceFile() + { + foreach (var resource in m_EmptyResourceTestCases!) + { + m_MockEconomyJsonConverter!.Setup( + x => x.SerializeObject( + It.IsAny(), + It.IsAny() + )) + .Returns(""); + + Assert.DoesNotThrow( + () => m_EconomyResourcesLoader!.ConstructResourceFile( + resource)); + + m_MockEconomyJsonConverter.Verify( + x => x.SerializeObject(It.IsAny(), It.IsAny()), + Times.Once); + + m_MockEconomyJsonConverter.Reset(); + } + } + + public static IEnumerable LoadFileTestCases + { + get + { + yield return new TestCaseData( + new EconomyCurrencyFile + { + Id = "Currency", + Initial = 0, + Max = 100, + Name = "Currency" + }, + EconomyResourcesExtensions.Currency + ); + yield return new TestCaseData( + new EconomyInventoryItemFile + { + Id = "Inventory_Item", + Name = "Inventory Item" + }, + EconomyResourcesExtensions.InventoryItem + ); + yield return new TestCaseData( + new EconomyVirtualPurchaseFile + { + Id = "Virtual_Purchase", + Name = "Virtual Purchase", + Costs = new[] + { + new Cost + { + ResourceId = "Currency", + Amount = 1 + } + }, + Rewards = new[] + { + new Reward + { + ResourceId = "Inventory_Item", + Amount = 1, + DefaultInstanceData = null + } + } + }, + EconomyResourcesExtensions.VirtualPurchase + ); + yield return new TestCaseData( + new EconomyRealMoneyPurchaseFile + { + Id = "Real_Money_Purchase", + Name = "Real Money Purchase", + Rewards = new[] + { + new RealMoneyReward + { + ResourceId = "Inventory_Item", + Amount = 1 + } + } + }, + EconomyResourcesExtensions.MoneyPurchase + ); + } + } + + [Test] + public async Task LoadResourceAsync_ReturnsCorrectResourceType() + { + foreach (var (file, path) in m_LoadFileTestCases!) + { + + if (path == EconomyResourcesExtensions.Currency) + { + m_MockEconomyJsonConverter! + .Setup(x => x.DeserializeObject(It.IsAny())) + .Returns(JsonConvert.DeserializeObject(JsonConvert.SerializeObject(file))); + } + + if (path == EconomyResourcesExtensions.InventoryItem) + { + m_MockEconomyJsonConverter! + .Setup(x => x.DeserializeObject(It.IsAny())) + .Returns(JsonConvert.DeserializeObject(JsonConvert.SerializeObject(file))); + } + + if (path == EconomyResourcesExtensions.VirtualPurchase) + { + m_MockEconomyJsonConverter! + .Setup(x => x.DeserializeObject(It.IsAny())) + .Returns(JsonConvert.DeserializeObject(JsonConvert.SerializeObject(file))); + } + + if (path == EconomyResourcesExtensions.MoneyPurchase) + { + m_MockEconomyJsonConverter! + .Setup(x => x.DeserializeObject(It.IsAny())) + .Returns(JsonConvert.DeserializeObject(JsonConvert.SerializeObject(file))); + } + + var resource = await m_EconomyResourcesLoader!.LoadResourceAsync( + path, + CancellationToken.None); + + Assert.That(resource.Status.Message, Is.SameAs(Statuses.Loaded)); + Assert.Multiple( + async () => + { + m_MockEconomyJsonConverter! + .Setup(x => x.DeserializeObject(It.IsAny())) + .Returns(JsonConvert.DeserializeObject(JsonConvert.SerializeObject(file))); + + var resource = await m_EconomyResourcesLoader!.LoadResourceAsync( + path, + CancellationToken.None); + + Assert.That(resource.Status.Message, Is.SameAs(Statuses.Loaded)); + Assert.Multiple( + () => + { + Assert.That(resource.Id, Is.EqualTo(file.Id)); + Assert.That(((EconomyResource)resource).EconomyType, Is.EqualTo(file.Type)); + Assert.That(resource.Name, Is.EqualTo(file.Name)); + Assert.That(resource.Path, Is.EqualTo(path)); + + if (resource is EconomyVirtualPurchase economyVirtualPurchase && + file is EconomyVirtualPurchaseFile economyVirtualPurchaseFile) + { + Assert.That( + economyVirtualPurchase.Costs.Length, + Is.EqualTo(economyVirtualPurchaseFile.Costs.Length)); + + for (int i = 0; i < economyVirtualPurchase.Costs.Length; i++) + { + Assert.That( + economyVirtualPurchase.Costs[i].ResourceId, + Is.EqualTo(economyVirtualPurchaseFile.Costs[i].ResourceId)); + Assert.That( + economyVirtualPurchase.Costs[i].Amount, + Is.EqualTo(economyVirtualPurchaseFile.Costs[i].Amount)); + } + + Assert.That( + economyVirtualPurchase.Rewards.Length, + Is.EqualTo(economyVirtualPurchaseFile.Rewards.Length)); + + for (int i = 0; i < economyVirtualPurchase.Rewards.Length; i++) + { + Assert.That( + economyVirtualPurchase.Rewards[i].ResourceId, + Is.EqualTo(economyVirtualPurchaseFile.Rewards[i].ResourceId)); + Assert.That( + economyVirtualPurchase.Rewards[i].Amount, + Is.EqualTo(economyVirtualPurchaseFile.Rewards[i].Amount)); + } + } + + if (resource is EconomyRealMoneyPurchase economyRealMoneyPurchase && + file is EconomyRealMoneyPurchaseFile economyRealMoneyPurchaseFile) + { + Assert.That( + economyRealMoneyPurchase.Rewards.Length, + Is.EqualTo(economyRealMoneyPurchaseFile.Rewards.Length)); + + for (int i = 0; i < economyRealMoneyPurchase.Rewards.Length; i++) + { + Assert.That( + economyRealMoneyPurchase.Rewards[i].ResourceId, + Is.EqualTo(economyRealMoneyPurchaseFile.Rewards[i].ResourceId)); + Assert.That( + economyRealMoneyPurchase.Rewards[i].Amount, + Is.EqualTo(economyRealMoneyPurchaseFile.Rewards[i].Amount)); + } + } + }); + }); + } + } + + [Test] + public async Task LoadResourcesAsync_FailsConfigValidation() + { + m_MockEconomyJsonConverter! + .Setup(x => x.DeserializeObject(It.IsAny())) + .Returns((Resource)null!); + + var deployContents = await m_EconomyResourcesLoader!.LoadResourceAsync( + "", + CancellationToken.None); + Assert.That(deployContents.Status.Message, Is.SameAs(Statuses.FailedToRead)); + } + + [Test] + public async Task LoadResourcesAsync_FailsCurrencyConfigValidation() + { + foreach (var res in m_ConfigValidationTestCases!) + { + m_MockEconomyJsonConverter! + .Setup(x => x.DeserializeObject(It.IsAny())) + .Returns(res); + + ConfigValidationMockHelper(res); + + var deployContents = await m_EconomyResourcesLoader!.LoadResourceAsync( + "", + CancellationToken.None); + + Assert.That(deployContents.Status.Message, Is.SameAs(Statuses.FailedToRead)); + } + } + + void ConfigValidationMockHelper(Resource res) + { + switch (res.Type) + { + case EconomyResourceTypes.InventoryItem: + m_MockEconomyJsonConverter! + .Setup(x => x.DeserializeObject(It.IsAny())) + .Returns((EconomyInventoryItemFile)null!); + break; + case EconomyResourceTypes.Currency: + m_MockEconomyJsonConverter! + .Setup(x => x.DeserializeObject(It.IsAny())) + .Returns((EconomyCurrencyFile)null!); + break; + case EconomyResourceTypes.VirtualPurchase: + m_MockEconomyJsonConverter! + .Setup(x => + x.DeserializeObject(It.IsAny())) + .Returns((EconomyVirtualPurchaseFile)null!); + break; + case EconomyResourceTypes.MoneyPurchase: + m_MockEconomyJsonConverter! + .Setup(x => + x.DeserializeObject(It.IsAny())) + .Returns((EconomyRealMoneyPurchaseFile)null!); + break; + } + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Economy.UnitTest/Authoring/Fetch/EconomyFetchServiceTests.cs b/Unity.Services.Cli/Unity.Services.Cli.Economy.UnitTest/Authoring/Fetch/EconomyFetchServiceTests.cs new file mode 100644 index 0000000..125ae8b --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Economy.UnitTest/Authoring/Fetch/EconomyFetchServiceTests.cs @@ -0,0 +1,172 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using NUnit.Framework; +using Unity.Services.Cli.Authoring.Input; +using Unity.Services.Cli.Common.Utils; +using Unity.Services.Cli.Economy.Authoring; +using Unity.Services.Cli.Economy.Authoring.Fetch; +using Unity.Services.DeploymentApi.Editor; +using Unity.Services.Economy.Editor.Authoring.Core.Fetch; +using Unity.Services.Economy.Editor.Authoring.Core.Model; +using FetchResult = Unity.Services.Cli.Authoring.Model.FetchResult; + +namespace Unity.Services.Cli.Economy.UnitTest.Authoring.Fetch; + +[TestFixture] +class EconomyFetchServiceTests +{ + const string k_ValidProjectId = "12345678-1111-2222-3333-123412341234"; + + readonly FetchInput m_FetchInput = new() + { + TargetEnvironmentName = "test", + CloudProjectId = k_ValidProjectId, + Path = "." + }; + + static readonly Mock k_UnityEnvironment = new(); + static readonly Mock k_EconomyClient = new(); + static readonly Mock k_EconomyResourcesLoader = new(); + static readonly Mock k_EconomyFetchHandler = new(); + + readonly EconomyFetchService m_EconomyService = new( + k_UnityEnvironment.Object, + k_EconomyClient.Object, + k_EconomyResourcesLoader.Object, + k_EconomyFetchHandler.Object); + + [SetUp] + public void SetUp() + { + k_UnityEnvironment.Reset(); + k_EconomyClient.Reset(); + k_EconomyResourcesLoader.Reset(); + k_EconomyFetchHandler.Reset(); + } + + // TODO: Test no longer useful, re-eval + [Test] + public async Task FetchAsyncReturnFetchResult() + { + var filePaths = new List() + { + "resource1.ec", + "resource2.ec", + "resource3.ec" + }; + + EconomyCurrency createdResource = new EconomyCurrency("RESOURCE_1") + { + Path = "resource1.ec" + }; + + EconomyCurrency updatedResource = new EconomyCurrency("RESOURCE_2") + { + Path = "resource2.ec" + }; + + EconomyCurrency deletedResource = new EconomyCurrency("RESOURCE_3") + { + Path = "resource3.ec" + }; + + List economyResources = new() + { + createdResource, + updatedResource, + deletedResource + }; + + var handlerFetchResult = new Unity.Services.Economy.Editor.Authoring.Core.Fetch.FetchResult + { + Created = new List + { + createdResource + }, + Updated = new List + { + updatedResource + }, + Deleted = new List + { + deletedResource + }, + Fetched = new List + { + updatedResource, + deletedResource, + createdResource, + }, + Failed = new List() + }; + + var expectedFetchResult = new FetchResult( + handlerFetchResult.Updated, + handlerFetchResult.Deleted, + handlerFetchResult.Created, + handlerFetchResult.Fetched, + Array.Empty()); + + for (int i = 0; i < filePaths.Count; i++) + { + k_EconomyResourcesLoader.Setup(e => + e.LoadResourceAsync(filePaths[i], CancellationToken.None)) + .ReturnsAsync(economyResources[i]); + } + + k_UnityEnvironment.Setup(u => u.FetchIdentifierAsync(CancellationToken.None)).ReturnsAsync(k_ValidProjectId); + + k_EconomyFetchHandler.Setup(e => + e.FetchAsync( + m_FetchInput.Path, + economyResources, + m_FetchInput.DryRun, + m_FetchInput.Reconcile, + CancellationToken.None)).ReturnsAsync(handlerFetchResult); + + var fetchResult = await m_EconomyService.FetchAsync( + m_FetchInput, + filePaths, + string.Empty, + string.Empty, + null, + CancellationToken.None); + var comparer = new DeploymentItemComparer(); + CollectionAssert.AreEqual(expectedFetchResult.Created, fetchResult.Created, comparer, "Created collections are not equal"); + CollectionAssert.AreEqual(expectedFetchResult.Updated, fetchResult.Updated, comparer, "Updated collections are not equal"); + CollectionAssert.AreEqual(expectedFetchResult.Fetched, fetchResult.Fetched, comparer, "Fetched collections are not equal"); + CollectionAssert.AreEqual(expectedFetchResult.Deleted, fetchResult.Deleted, comparer, "Deleted collections are not equal"); + CollectionAssert.AreEqual(expectedFetchResult.Failed, fetchResult.Failed, comparer, "Failed collections are not equal"); + } + + class DeploymentItemComparer : IComparer + { + public int Compare(object? x, object? y) + { + if (x is IDeploymentItem xx && y is IDeploymentItem yy) + return CompareInternal(xx, yy); + return -1; + } + + static int CompareInternal( + IDeploymentItem x, + IDeploymentItem y) + { + if (ReferenceEquals(x, y)) return 0; + if (ReferenceEquals(null, y)) return 1; + if (ReferenceEquals(null, x)) return -1; + var nameComparison = string.Compare(x.Name, y.Name, StringComparison.Ordinal); + if (nameComparison != 0) + return nameComparison; + var pathComparison = string.Compare(x.Path, y.Path, StringComparison.Ordinal); + if (pathComparison != 0) + return pathComparison; + var progressComparison = x.Progress.CompareTo(y.Progress); + return progressComparison; + } + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Economy.UnitTest/EconomyModuleTests.cs b/Unity.Services.Cli/Unity.Services.Cli.Economy.UnitTest/EconomyModuleTests.cs new file mode 100644 index 0000000..295c81c --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Economy.UnitTest/EconomyModuleTests.cs @@ -0,0 +1,76 @@ +using System; +using System.Collections.Generic; +using System.CommandLine.Builder; +using System.Linq; +using System.Reflection; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; +using Unity.Services.Cli.Common; +using Unity.Services.Cli.Common.Input; +using Unity.Services.Cli.Common.Networking; +using Unity.Services.Cli.Common.Validator; +using Unity.Services.Cli.Economy.Service; +using Unity.Services.Cli.TestUtils; +using Unity.Services.Gateway.EconomyApiV2.Generated.Api; + +namespace Unity.Services.Cli.Economy.UnitTest; + +[TestFixture] +public class EconomyModuleTests +{ + static readonly EconomyModule k_EconomyModule = new(); + + [Test] + public void BuildCommands_CreateEconomyCommands() + { + var commandLineBuilder = new CommandLineBuilder(); + commandLineBuilder.AddModule(k_EconomyModule); + TestsHelper.AssertContainsCommand(commandLineBuilder.Command, k_EconomyModule.ModuleRootCommand!.Name, + out var resultCommand); + Assert.AreEqual(k_EconomyModule.ModuleRootCommand, resultCommand); + Assert.NotNull(k_EconomyModule.GetResourcesCommand!.Handler); + Assert.NotNull(k_EconomyModule.GetPublishedCommand!.Handler); + Assert.NotNull(k_EconomyModule.PublishCommand!.Handler); + Assert.NotNull(k_EconomyModule.DeleteCommand!.Handler); + } + + [Test] + public void BuildCommands_CreateAliases() + { + var commandLineBuilder = new CommandLineBuilder(); + commandLineBuilder.AddModule(k_EconomyModule); + + Assert.IsTrue(k_EconomyModule.ModuleRootCommand!.Aliases.Contains("ec")); + } + + [Test] + public void Commands_ContainsRequiredInputs() + { + Assert.IsTrue(k_EconomyModule.GetResourcesCommand!.Options.Contains(CommonInput.CloudProjectIdOption)); + Assert.IsTrue(k_EconomyModule.GetResourcesCommand.Options.Contains(CommonInput.EnvironmentNameOption)); + + Assert.IsTrue(k_EconomyModule.GetPublishedCommand!.Options.Contains(CommonInput.CloudProjectIdOption)); + Assert.IsTrue(k_EconomyModule.GetPublishedCommand.Options.Contains(CommonInput.EnvironmentNameOption)); + + Assert.IsTrue(k_EconomyModule.PublishCommand!.Options.Contains(CommonInput.CloudProjectIdOption)); + Assert.IsTrue(k_EconomyModule.PublishCommand.Options.Contains(CommonInput.EnvironmentNameOption)); + + Assert.IsTrue(k_EconomyModule.DeleteCommand!.Options.Contains(CommonInput.CloudProjectIdOption)); + Assert.IsTrue(k_EconomyModule.DeleteCommand.Options.Contains(CommonInput.EnvironmentNameOption)); + } + + [TestCase(typeof(IEconomyService))] + [TestCase(typeof(IConfigurationValidator))] + [TestCase(typeof(IEconomyAdminApiAsync))] + public void ConfigureEconomyRegistersExpectedServices(Type serviceType) + { + EndpointHelper.InitializeNetworkTargetEndpoints(new[] + { + typeof(UnityServicesGatewayEndpoints).GetTypeInfo() + }); + var services = new List(); + var hostBuilder = TestsHelper.CreateAndSetupMockHostBuilder(services); + hostBuilder.ConfigureServices(EconomyModule.RegisterServices); + Assert.That(services.FirstOrDefault(c => c.ServiceType == serviceType), Is.Not.Null); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Economy.UnitTest/Handlers/DeleteHandlerTests.cs b/Unity.Services.Cli/Unity.Services.Cli.Economy.UnitTest/Handlers/DeleteHandlerTests.cs new file mode 100644 index 0000000..fee2aee --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Economy.UnitTest/Handlers/DeleteHandlerTests.cs @@ -0,0 +1,72 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using NUnit.Framework; +using Unity.Services.Cli.Economy.Service; +using Microsoft.Extensions.Logging; +using Spectre.Console; +using Unity.Services.Cli.Common.Console; +using Unity.Services.Cli.Common.Utils; +using Unity.Services.Cli.Economy.Handlers; +using Unity.Services.Cli.Economy.Input; +using Unity.Services.Cli.Economy.UnitTest.Utils; + +namespace Unity.Services.Cli.Economy.UnitTest.Handlers; + +public class DeleteHandlerTests +{ + readonly Mock? m_MockEconomy = new(); + readonly Mock m_MockUnityEnvironment = new(); + readonly Mock? m_MockLogger = new(); + + [SetUp] + public void SetUp() + { + m_MockEconomy.Reset(); + m_MockUnityEnvironment.Reset(); + m_MockLogger.Reset(); + } + + [Test] + public async Task DeleteAsync_CallsLoadingIndicator() + { + Mock mockLoadingIndicator = new Mock(); + + await DeleteHandler.DeleteAsync(null!, null!, null!, null!, mockLoadingIndicator.Object, CancellationToken.None); + + mockLoadingIndicator.Verify(ex => ex + .StartLoadingAsync(It.IsAny(), It.IsAny>()), Times.Once); + } + + [Test] + public async Task DeleteHandler_CallsServiceAndLogger_WhenInputIsValid() + { + var resourceId = "resource_id"; + + EconomyInput input = new() + { + ResourceId = resourceId, + CloudProjectId = TestValues.ValidProjectId, + }; + + m_MockUnityEnvironment.Setup(x => x.FetchIdentifierAsync(CancellationToken.None)) + .ReturnsAsync(TestValues.ValidEnvironmentId); + + m_MockEconomy!.Setup(x => x.DeleteAsync(resourceId, TestValues.ValidProjectId, TestValues.ValidEnvironmentId, + CancellationToken.None)); + + await DeleteHandler.DeleteAsync( + input, + m_MockUnityEnvironment.Object, + m_MockEconomy!.Object, + m_MockLogger!.Object, + CancellationToken.None + ); + + m_MockUnityEnvironment.Verify(x => x.FetchIdentifierAsync(CancellationToken.None), Times.Once); + m_MockEconomy.Verify(ex => ex.DeleteAsync(resourceId, TestValues.ValidProjectId, TestValues.ValidEnvironmentId, + CancellationToken.None), Times.Once); + } + +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Economy.UnitTest/Handlers/EconomyConfigurationBuilderTests.cs b/Unity.Services.Cli/Unity.Services.Cli.Economy.UnitTest/Handlers/EconomyConfigurationBuilderTests.cs new file mode 100644 index 0000000..363fbca --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Economy.UnitTest/Handlers/EconomyConfigurationBuilderTests.cs @@ -0,0 +1,133 @@ +using NUnit.Framework; +using Unity.Services.Cli.Economy.Handlers; +using Unity.Services.Economy.Editor.Authoring.Core.Model; +using Unity.Services.Gateway.EconomyApiV2.Generated.Model; + +namespace Unity.Services.Cli.Economy.UnitTest.Handlers; + +public class EconomyConfigurationBuilderTests +{ + IEconomyResource? m_EconomyCurrencyResource; + IEconomyResource? m_EconomyInventoryItemResource; + IEconomyResource? m_EconomyVirtualPurchaseResource; + IEconomyResource? m_EconomyRealMoneyPurchaseResource; + + [SetUp] + public void Setup() + { + m_EconomyCurrencyResource = new EconomyCurrency("NEW_CURRENCY") + { + Name = "New Currency", + Initial = 10, + Max = 1000, + }; + + m_EconomyInventoryItemResource = new EconomyInventoryItem("SHIELD") + { + Name = "Shield" + }; + + m_EconomyVirtualPurchaseResource = new EconomyVirtualPurchase("VIRTUAL_PURCHASE") + { + Name = "Virtual Purchase", + Costs = new[] + { + new Cost() + { + ResourceId = "SILVER", + Amount = 10 + } + }, + Rewards = new[] + { + new Reward() + { + ResourceId = "GOLD", + Amount = 1 + } + } + }; + + m_EconomyRealMoneyPurchaseResource = new EconomyRealMoneyPurchase("APPLE_PURCHASE") + { + Name = "Apple Purchase", + StoreIdentifiers = new StoreIdentifiers() + { + AppleAppStore = "test", + GooglePlayStore = "google_test" + }, + Rewards = new[] + { + new RealMoneyReward() + { + ResourceId = "SWORD", + Amount = 1 + } + } + }; + } + + [Test] + public void EconomyConfigurationBuilder_CreatesValidConfigRequest_ForCurrencyResource() + { + var currencyResourceRequest = EconomyConfigurationBuilder + .ConstructAddConfigResourceRequest(m_EconomyCurrencyResource!)! + .GetCurrencyItemRequest(); + + Assert.AreEqual("NEW_CURRENCY", currencyResourceRequest.Id); + Assert.AreEqual("New Currency", currencyResourceRequest.Name); + Assert.AreEqual(CurrencyItemRequest.TypeEnum.CURRENCY, currencyResourceRequest.Type); + Assert.AreEqual(10, currencyResourceRequest.Initial); + Assert.AreEqual(1000, currencyResourceRequest.Max); + Assert.AreEqual(null, currencyResourceRequest.CustomData); + } + + [Test] + public void EconomyConfigurationBuilder_CreatesValidConfigRequest_ForInventoryItemResource() + { + var inventoryItemRequest = EconomyConfigurationBuilder + .ConstructAddConfigResourceRequest(m_EconomyInventoryItemResource!)! + .GetInventoryItemRequest(); + + Assert.AreEqual("SHIELD", inventoryItemRequest.Id); + Assert.AreEqual("Shield", inventoryItemRequest.Name); + Assert.AreEqual(InventoryItemRequest.TypeEnum.INVENTORYITEM, inventoryItemRequest.Type); + Assert.AreEqual(null, inventoryItemRequest.CustomData); + } + + [Test] + public void EconomyConfigurationBuilder_CreatesValidConfigRequest_ForVirtualPurchaseResource() + { + var virtualPurchaseResourceRequest = EconomyConfigurationBuilder + .ConstructAddConfigResourceRequest(m_EconomyVirtualPurchaseResource!)! + .GetVirtualPurchaseResourceRequest(); + + Assert.AreEqual("VIRTUAL_PURCHASE", virtualPurchaseResourceRequest.Id); + Assert.AreEqual("Virtual Purchase", virtualPurchaseResourceRequest.Name); + Assert.AreEqual(VirtualPurchaseResourceRequest.TypeEnum.VIRTUALPURCHASE, virtualPurchaseResourceRequest.Type); + Assert.AreEqual(10, virtualPurchaseResourceRequest.Costs[0].Amount); + Assert.AreEqual("SILVER", virtualPurchaseResourceRequest.Costs[0].ResourceId); + Assert.AreEqual(1, virtualPurchaseResourceRequest.Rewards[0].Amount); + Assert.AreEqual("GOLD", virtualPurchaseResourceRequest.Rewards[0].ResourceId); + Assert.AreEqual(null, virtualPurchaseResourceRequest.Rewards[0].DefaultInstanceData); + Assert.AreEqual(null, virtualPurchaseResourceRequest.CustomData); + } + + [Test] + public void EconomyConfigurationBuilder_CreatesValidConfigRequest_ForRealMoneyPurchaseResource() + { + var moneyPurchaseResourceRequest = EconomyConfigurationBuilder + .ConstructAddConfigResourceRequest(m_EconomyRealMoneyPurchaseResource!)! + .GetRealMoneyPurchaseResourceRequest(); + + Assert.AreEqual("APPLE_PURCHASE", moneyPurchaseResourceRequest.Id); + Assert.AreEqual("Apple Purchase", moneyPurchaseResourceRequest.Name); + Assert.AreEqual(RealMoneyPurchaseResourceRequest.TypeEnum.MONEYPURCHASE, moneyPurchaseResourceRequest.Type); + Assert.AreEqual("test", moneyPurchaseResourceRequest.StoreIdentifiers.AppleAppStore); + Assert.AreEqual("google_test", moneyPurchaseResourceRequest.StoreIdentifiers.GooglePlayStore); + Assert.AreEqual(1, moneyPurchaseResourceRequest.Rewards[0].Amount); + Assert.AreEqual("SWORD", moneyPurchaseResourceRequest.Rewards[0].ResourceId); + Assert.AreEqual(null, moneyPurchaseResourceRequest.CustomData); + } + +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Economy.UnitTest/Handlers/GetPublishedHandlerTests.cs b/Unity.Services.Cli/Unity.Services.Cli.Economy.UnitTest/Handlers/GetPublishedHandlerTests.cs new file mode 100644 index 0000000..06d3f5c --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Economy.UnitTest/Handlers/GetPublishedHandlerTests.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using NUnit.Framework; +using Unity.Services.Cli.Economy.Service; +using Microsoft.Extensions.Logging; +using Spectre.Console; +using Unity.Services.Cli.Common.Console; +using Unity.Services.Cli.Common.Input; +using Unity.Services.Cli.Common.Logging; +using Unity.Services.Cli.Common.Utils; +using Unity.Services.Cli.Economy.Handlers; +using Unity.Services.Cli.Economy.UnitTest.Utils; +using Unity.Services.Cli.TestUtils; +using Unity.Services.Gateway.EconomyApiV2.Generated.Model; + +namespace Unity.Services.Cli.Economy.UnitTest.Handlers; + +public class GetPublishedHandlerTests +{ + readonly Mock m_MockEconomy = new(); + readonly Mock m_MockUnityEnvironment = new(); + readonly Mock m_MockLogger = new(); + + [SetUp] + public void SetUp() + { + m_MockUnityEnvironment.Reset(); + m_MockLogger.Reset(); + m_MockEconomy.Reset(); + } + + [Test] + public async Task GetPublished_CallsLoadingIndicator() + { + Mock mockLoadingIndicator = new Mock(); + + await GetPublishedHandler.GetAsync(null!, null!, null!, null!, mockLoadingIndicator.Object, CancellationToken.None); + + mockLoadingIndicator.Verify(ex => ex + .StartLoadingAsync(It.IsAny(), It.IsAny>()), Times.Once); + } + + [Test] + public async Task GetPublishedHandler_CallsServiceAndLogger_WhenInputIsValid() + { + CommonInput input = new() + { + CloudProjectId = TestValues.ValidProjectId, + }; + + + m_MockUnityEnvironment.Setup(x => x.FetchIdentifierAsync(CancellationToken.None)) + .ReturnsAsync(TestValues.ValidEnvironmentId); + + CurrencyItemResponse currency = new CurrencyItemResponse( + "id", + "name", + CurrencyItemResponse.TypeEnum.CURRENCY, + 0, + 100, + "custom data", + new ModifiedMetadata(DateTime.Now), + new ModifiedMetadata(DateTime.Now) + ); + + GetResourcesResponseResultsInner response = new GetResourcesResponseResultsInner(currency); + m_MockEconomy.Setup(x => x.GetPublishedAsync(TestValues.ValidProjectId, TestValues.ValidEnvironmentId, CancellationToken.None)) + .ReturnsAsync(new List { response }); + + await GetPublishedHandler.GetAsync( + input, + m_MockUnityEnvironment.Object, + m_MockEconomy!.Object, + m_MockLogger!.Object, + CancellationToken.None + ); + + m_MockUnityEnvironment.Verify(x => x.FetchIdentifierAsync(CancellationToken.None), Times.Once); + m_MockEconomy.Verify(ex => ex.GetPublishedAsync(TestValues.ValidProjectId, TestValues.ValidEnvironmentId, + CancellationToken.None), Times.Once); + + TestsHelper.VerifyLoggerWasCalled(m_MockLogger, LogLevel.Critical, LoggerExtension.ResultEventId, Times.Once); + } + +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Economy.UnitTest/Handlers/GetResourcesHandlerTests.cs b/Unity.Services.Cli/Unity.Services.Cli.Economy.UnitTest/Handlers/GetResourcesHandlerTests.cs new file mode 100644 index 0000000..a5fd6d0 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Economy.UnitTest/Handlers/GetResourcesHandlerTests.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using NUnit.Framework; +using Unity.Services.Cli.Economy.Service; +using Microsoft.Extensions.Logging; +using Spectre.Console; +using Unity.Services.Cli.Common.Console; +using Unity.Services.Cli.Common.Input; +using Unity.Services.Cli.Common.Logging; +using Unity.Services.Cli.Common.Utils; +using Unity.Services.Cli.Economy.Handlers; +using Unity.Services.Cli.Economy.UnitTest.Utils; +using Unity.Services.Cli.TestUtils; +using Unity.Services.Gateway.EconomyApiV2.Generated.Model; + +namespace Unity.Services.Cli.Economy.UnitTest.Handlers; + +public class GetResourcesHandlerTests +{ + Mock? m_MockEconomy; + readonly Mock m_MockUnityEnvironment = new(); + Mock? m_MockLogger; + + [SetUp] + public void SetUp() + { + m_MockUnityEnvironment.Reset(); + m_MockLogger = new Mock(); + m_MockEconomy = new Mock(); + } + + [Test] + public async Task GetAsync_CallsLoadingIndicator() + { + Mock mockLoadingIndicator = new Mock(); + + await GetResourcesHandler.GetAsync(null!, null!, null!, null!, mockLoadingIndicator.Object, CancellationToken.None); + + mockLoadingIndicator.Verify(ex => ex + .StartLoadingAsync(It.IsAny(), It.IsAny>()), Times.Once); + } + + [Test] + public async Task GetResourcesHandler_CallsServiceAndLogger_WhenInputIsValid() + { + CommonInput input = new() + { + CloudProjectId = TestValues.ValidProjectId, + }; + + + m_MockUnityEnvironment.Setup(x => x.FetchIdentifierAsync(CancellationToken.None)) + .ReturnsAsync(TestValues.ValidEnvironmentId); + + CurrencyItemResponse currency = new CurrencyItemResponse( + "id", + "name", + CurrencyItemResponse.TypeEnum.CURRENCY, + 0, + 100, + "custom data", + new ModifiedMetadata(DateTime.Now), + new ModifiedMetadata(DateTime.Now) + ); + + GetResourcesResponseResultsInner response = new GetResourcesResponseResultsInner(currency); + m_MockEconomy!.Setup(x => x.GetResourcesAsync(TestValues.ValidProjectId, TestValues.ValidEnvironmentId, CancellationToken.None)) + .ReturnsAsync(new List { response }); + + await GetResourcesHandler.GetAsync( + input, + m_MockUnityEnvironment.Object, + m_MockEconomy!.Object, + m_MockLogger!.Object, + CancellationToken.None + ); + + m_MockUnityEnvironment.Verify(x => x.FetchIdentifierAsync(CancellationToken.None), Times.Once); + m_MockEconomy.Verify(ex => ex.GetResourcesAsync(TestValues.ValidProjectId, TestValues.ValidEnvironmentId, + CancellationToken.None), Times.Once); + + TestsHelper.VerifyLoggerWasCalled(m_MockLogger, LogLevel.Critical, LoggerExtension.ResultEventId, Times.Once); + } + +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Economy.UnitTest/Handlers/PublishHandlerTests.cs b/Unity.Services.Cli/Unity.Services.Cli.Economy.UnitTest/Handlers/PublishHandlerTests.cs new file mode 100644 index 0000000..f04a3b4 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Economy.UnitTest/Handlers/PublishHandlerTests.cs @@ -0,0 +1,73 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using NUnit.Framework; +using Unity.Services.Cli.Economy.Service; +using Microsoft.Extensions.Logging; +using Spectre.Console; +using Unity.Services.Cli.Common.Console; +using Unity.Services.Cli.Common.Input; +using Unity.Services.Cli.Common.Utils; +using Unity.Services.Cli.Economy.Handlers; +using Unity.Services.Cli.Economy.UnitTest.Utils; +using Unity.Services.Cli.TestUtils; + +namespace Unity.Services.Cli.Economy.UnitTest.Handlers; + +public class PublishHandlerTests +{ + readonly Mock? m_MockEconomy = new(); + readonly Mock m_MockUnityEnvironment = new(); + readonly Mock? m_MockLogger = new(); + + [SetUp] + public void SetUp() + { + m_MockUnityEnvironment.Reset(); + m_MockLogger.Reset(); + m_MockEconomy.Reset(); + } + + [Test] + public async Task PublishAsync_CallsLoadingIndicator() + { + Mock mockLoadingIndicator = new Mock(); + + await PublishHandler.PublishAsync(null!, null!, null!, null!, mockLoadingIndicator.Object, CancellationToken.None); + + mockLoadingIndicator.Verify(ex => ex + .StartLoadingAsync(It.IsAny(), It.IsAny>()), Times.Once); + } + + [Test] + public async Task PublishHandler_CallsServiceAndLogger_WhenInputIsValid() + { + CommonInput input = new() + { + CloudProjectId = TestValues.ValidProjectId, + }; + + + m_MockUnityEnvironment.Setup(x => x.FetchIdentifierAsync(CancellationToken.None)) + .ReturnsAsync(TestValues.ValidEnvironmentId); + + m_MockEconomy!.Setup(x => + x.PublishAsync(TestValues.ValidProjectId, TestValues.ValidEnvironmentId, CancellationToken.None)); + + await PublishHandler.PublishAsync( + input, + m_MockUnityEnvironment.Object, + m_MockEconomy!.Object, + m_MockLogger!.Object, + CancellationToken.None + ); + + m_MockUnityEnvironment.Verify(x => x.FetchIdentifierAsync(CancellationToken.None), Times.Once); + m_MockEconomy.Verify(ex => ex.PublishAsync(TestValues.ValidProjectId, TestValues.ValidEnvironmentId, + CancellationToken.None), Times.Once); + + TestsHelper.VerifyLoggerWasCalled(m_MockLogger, LogLevel.Information, expectedTimes: Times.Once); + } + +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Economy.UnitTest/Mock/EconomyApiV2AsyncMock.cs b/Unity.Services.Cli/Unity.Services.Cli.Economy.UnitTest/Mock/EconomyApiV2AsyncMock.cs new file mode 100644 index 0000000..dea60f2 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Economy.UnitTest/Mock/EconomyApiV2AsyncMock.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using Moq; +using Unity.Services.Gateway.EconomyApiV2.Generated.Api; +using Unity.Services.Gateway.EconomyApiV2.Generated.Model; + +namespace Unity.Services.Cli.Economy.UnitTest.Mock; + +class EconomyApiV2AsyncMock +{ + public Mock DefaultApiAsyncObject = new(); + + public GetResourcesResponse GetResourcesResponse { get; } = new( + new List()); + + public GetPublishedResourcesResponse GetPublishedResponse { get; } = new( + new List()); + + public void SetUp() + { + DefaultApiAsyncObject = new Mock(); + + DefaultApiAsyncObject.Setup( + a => a.GetResourcesAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + CancellationToken.None)) + .ReturnsAsync(GetResourcesResponse); + + DefaultApiAsyncObject.Setup( + a => a.GetPublishedResourcesAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + CancellationToken.None)) + .ReturnsAsync(GetPublishedResponse); + + DefaultApiAsyncObject.Setup(a => a.Configuration) + .Returns(new Gateway.EconomyApiV2.Generated.Client.Configuration()); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Economy.UnitTest/Service/EconomyServiceTests.cs b/Unity.Services.Cli/Unity.Services.Cli.Economy.UnitTest/Service/EconomyServiceTests.cs new file mode 100644 index 0000000..fd81a6e --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Economy.UnitTest/Service/EconomyServiceTests.cs @@ -0,0 +1,505 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Moq; +using NUnit.Framework; +using Unity.Services.Cli.Common.Exceptions; +using Unity.Services.Cli.Common.Models; +using Unity.Services.Cli.Common.Validator; +using Unity.Services.Cli.Economy.Service; +using Unity.Services.Cli.Economy.UnitTest.Mock; +using Unity.Services.Cli.Economy.UnitTest.Utils; +using Unity.Services.Cli.ServiceAccountAuthentication; +using Unity.Services.Cli.ServiceAccountAuthentication.Token; +using Unity.Services.Gateway.EconomyApiV2.Generated.Api; +using Unity.Services.Gateway.EconomyApiV2.Generated.Model; + +namespace Unity.Services.Cli.Economy.UnitTest.Service; + +[TestFixture] +public class EconomyServiceTests +{ + const string k_TestAccessToken = "test-token"; + const string k_InvalidProjectId = "invalidProject"; + const string k_InvalidEnvironmentId = "foo"; + + readonly Mock m_ValidatorObject = new(); + readonly Mock m_AuthenticationServiceObject = new(); + readonly EconomyApiV2AsyncMock m_EconomyApiV2AsyncMock = new(); + readonly Mock m_DefaultApiAsyncObject = new(); + readonly Mock? m_MockLogger = new(); + + List? m_ExpectedResources; + + EconomyService? m_EconomyService; + + [SetUp] + public void SetUp() + { + m_DefaultApiAsyncObject.Reset(); + m_ValidatorObject.Reset(); + m_AuthenticationServiceObject.Reset(); + m_MockLogger.Reset(); + m_AuthenticationServiceObject.Setup(a => a.GetAccessTokenAsync(CancellationToken.None)) + .Returns(Task.FromResult(k_TestAccessToken)); + + // Setup GetResources/GetPublished responses + var currency = new CurrencyItemResponse( + "id", + "name", + CurrencyItemResponse.TypeEnum.CURRENCY, + 0, + 100, + "custom data", + new ModifiedMetadata(DateTime.Now), + new ModifiedMetadata(DateTime.Now) + ); + GetResourcesResponseResultsInner response = new GetResourcesResponseResultsInner(currency); + m_ExpectedResources = new List { response }; + m_EconomyApiV2AsyncMock.GetResourcesResponse.Results = m_ExpectedResources; + m_EconomyApiV2AsyncMock.GetPublishedResponse.Results = m_ExpectedResources; + + m_EconomyApiV2AsyncMock.SetUp(); + + m_EconomyService = new EconomyService( + m_EconomyApiV2AsyncMock.DefaultApiAsyncObject.Object, + m_ValidatorObject.Object, + m_AuthenticationServiceObject.Object); + } + + [Test] + public async Task AuthorizeEconomyService() + { + await m_EconomyService!.AuthorizeService(CancellationToken.None); + m_AuthenticationServiceObject.Verify(a => a.GetAccessTokenAsync(CancellationToken.None)); + Assert.AreEqual( + k_TestAccessToken.ToHeaderValue(), + m_EconomyApiV2AsyncMock.DefaultApiAsyncObject.Object.Configuration.DefaultHeaders[ + AccessTokenHelper.HeaderKey]); + } + + // Get resources tests ----------------------------------------------------- + [Test] + public async Task GetResourcesAsync_EmptyConfigSuccess() + { + string mockErrorMsg; + m_ValidatorObject.Setup(v => v.IsConfigValid(It.IsAny(), It.IsAny(), out mockErrorMsg)) + .Returns(true); + m_ExpectedResources!.Clear(); + + var actualResources = await m_EconomyService!.GetResourcesAsync( + TestValues.ValidProjectId, TestValues.ValidEnvironmentId, CancellationToken.None); + + Assert.AreEqual(0, actualResources.Count); + } + + [Test] + public async Task GetResourcesAsync_WithValidParams_GetsExpectedResources() + { + string mockErrorMsg; + m_ValidatorObject.Setup(v => v.IsConfigValid(It.IsAny(), It.IsAny(), out mockErrorMsg)) + .Returns(true); + + var actualResources = await m_EconomyService!.GetResourcesAsync( + TestValues.ValidProjectId, TestValues.ValidEnvironmentId, CancellationToken.None); + + CollectionAssert.AreEqual(m_ExpectedResources, actualResources); + m_EconomyApiV2AsyncMock.DefaultApiAsyncObject.Verify( + a => a.GetResourcesAsync( + TestValues.ValidProjectId, + Guid.Parse(TestValues.ValidEnvironmentId), + null, + null, + 0, + CancellationToken.None), + Times.Once); + } + + [Test] + public void GetResourcesAsync_InvalidProjectId_ThrowsConfigValidationException() + { + m_ValidatorObject.Setup(v => v.ThrowExceptionIfConfigInvalid(Keys.ConfigKeys.ProjectId, k_InvalidProjectId)) + .Throws(new ConfigValidationException(Keys.ConfigKeys.ProjectId, k_InvalidProjectId, It.IsAny())); + + Assert.ThrowsAsync( + () => m_EconomyService!.GetResourcesAsync( + k_InvalidProjectId, TestValues.ValidEnvironmentId, CancellationToken.None)); + + m_EconomyApiV2AsyncMock.DefaultApiAsyncObject.Verify( + a => a.GetResourcesAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Test] + public void GetResourcesAsync_InvalidEnvironmentId_ThrowsConfigValidationException() + { + m_ValidatorObject.Setup( + v => v.ThrowExceptionIfConfigInvalid(Keys.ConfigKeys.EnvironmentId, k_InvalidEnvironmentId)) + .Throws(new ConfigValidationException(Keys.ConfigKeys.EnvironmentId, k_InvalidEnvironmentId, It.IsAny())); + + Assert.ThrowsAsync( + () => m_EconomyService!.GetResourcesAsync( + TestValues.ValidProjectId, k_InvalidEnvironmentId, CancellationToken.None)); + + m_EconomyApiV2AsyncMock.DefaultApiAsyncObject.Verify( + a => a.GetResourcesAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + // Get published resources tests ----------------------------------------------------- + + [Test] + public async Task GetPublishedResourcesAsync_EmptyConfigSuccess() + { + string mockErrorMsg; + m_ValidatorObject.Setup(v => v.IsConfigValid(It.IsAny(), It.IsAny(), out mockErrorMsg)) + .Returns(true); + m_ExpectedResources!.Clear(); + + var actualResources = await m_EconomyService!.GetPublishedAsync( + TestValues.ValidProjectId, TestValues.ValidEnvironmentId, CancellationToken.None); + + Assert.AreEqual(0, actualResources.Count); + } + + [Test] + public async Task GetPublishedAsync_WithValidParams_GetsExpectedResources() + { + string mockErrorMsg; + m_ValidatorObject.Setup(v => v.IsConfigValid(It.IsAny(), It.IsAny(), out mockErrorMsg)) + .Returns(true); + + var actualResources = await m_EconomyService!.GetPublishedAsync( + TestValues.ValidProjectId, TestValues.ValidEnvironmentId, CancellationToken.None); + + CollectionAssert.AreEqual(m_ExpectedResources, actualResources); + m_EconomyApiV2AsyncMock.DefaultApiAsyncObject.Verify( + a => a.GetPublishedResourcesAsync( + TestValues.ValidProjectId, + Guid.Parse(TestValues.ValidEnvironmentId), + null, + null, + 0, + CancellationToken.None), + Times.Once); + } + + [Test] + public void GetPublishedResourcesAsync_InvalidProjectId_ThrowsConfigValidationException() + { + m_ValidatorObject.Setup(v => v.ThrowExceptionIfConfigInvalid(Keys.ConfigKeys.ProjectId, k_InvalidProjectId)) + .Throws(new ConfigValidationException(Keys.ConfigKeys.ProjectId, k_InvalidProjectId, It.IsAny())); + + Assert.ThrowsAsync( + () => m_EconomyService!.GetPublishedAsync( + k_InvalidProjectId, TestValues.ValidEnvironmentId, CancellationToken.None)); + + m_EconomyApiV2AsyncMock.DefaultApiAsyncObject.Verify( + a => a.GetPublishedResourcesAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Test] + public void GetPublishedResourcesAsync_InvalidEnvironmentId_ThrowsConfigValidationException() + { + m_ValidatorObject.Setup( + v => v.ThrowExceptionIfConfigInvalid(Keys.ConfigKeys.EnvironmentId, k_InvalidEnvironmentId)) + .Throws(new ConfigValidationException(Keys.ConfigKeys.EnvironmentId, k_InvalidEnvironmentId, It.IsAny())); + + Assert.ThrowsAsync( + () => m_EconomyService!.GetPublishedAsync( + TestValues.ValidProjectId, k_InvalidEnvironmentId, CancellationToken.None)); + + m_EconomyApiV2AsyncMock.DefaultApiAsyncObject.Verify( + a => a.GetPublishedResourcesAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + // Publish tests ----------------------------------------------------- + + [Test] + public async Task PublishAsync_PublishesConfiguration() + { + string mockErrorMsg; + m_ValidatorObject.Setup(v => v.IsConfigValid(It.IsAny(), It.IsAny(), out mockErrorMsg)) + .Returns(true); + + await m_EconomyService!.PublishAsync(TestValues.ValidProjectId, TestValues.ValidEnvironmentId, CancellationToken.None); + + m_EconomyApiV2AsyncMock.DefaultApiAsyncObject.Verify( + a => a.PublishEconomyAsync( + TestValues.ValidProjectId, + Guid.Parse(TestValues.ValidEnvironmentId), + new PublishBody(true), + 0, + CancellationToken.None), + Times.Once); + } + + [Test] + public void PublishAsync_InvalidProjectId_ThrowsConfigValidationException() + { + m_ValidatorObject.Setup(v => v.ThrowExceptionIfConfigInvalid(Keys.ConfigKeys.ProjectId, k_InvalidProjectId)) + .Throws(new ConfigValidationException(Keys.ConfigKeys.ProjectId, k_InvalidProjectId, It.IsAny())); + + Assert.ThrowsAsync( + () => m_EconomyService!.PublishAsync( + k_InvalidProjectId, TestValues.ValidEnvironmentId, CancellationToken.None)); + + m_EconomyApiV2AsyncMock.DefaultApiAsyncObject.Verify( + a => a.PublishEconomyAsync( + It.IsAny(), + It.IsAny(), + new PublishBody(true), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Test] + public void PublishAsync_InvalidEnvironmentId_ThrowsConfigValidationException() + { + m_ValidatorObject.Setup( + v => v.ThrowExceptionIfConfigInvalid(Keys.ConfigKeys.EnvironmentId, k_InvalidEnvironmentId)) + .Throws(new ConfigValidationException(Keys.ConfigKeys.EnvironmentId, k_InvalidEnvironmentId, It.IsAny())); + + Assert.ThrowsAsync( + () => m_EconomyService!.PublishAsync( + TestValues.ValidProjectId, k_InvalidEnvironmentId, CancellationToken.None)); + + m_EconomyApiV2AsyncMock.DefaultApiAsyncObject.Verify( + a => a.PublishEconomyAsync( + It.IsAny(), + It.IsAny(), + new PublishBody(true), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + // Delete resource tests ----------------------------------------------------- + [Test] + public async Task DeleteResourceAsync_WithValidId_DeletesResource() + { + string mockErrorMsg; + m_ValidatorObject.Setup(v => v.IsConfigValid(It.IsAny(), It.IsAny(), out mockErrorMsg)) + .Returns(true); + + await m_EconomyService!.DeleteAsync("resource_id", TestValues.ValidProjectId, TestValues.ValidEnvironmentId, CancellationToken.None); + + m_EconomyApiV2AsyncMock.DefaultApiAsyncObject.Verify( + a => a.DeleteConfigResourceAsync( + TestValues.ValidProjectId, + Guid.Parse(TestValues.ValidEnvironmentId), + "resource_id", + 0, + CancellationToken.None), + Times.Once); + } + + [Test] + public void DeleteResourceAsync_InvalidProjectId_ThrowsConfigValidationException() + { + m_ValidatorObject.Setup(v => v.ThrowExceptionIfConfigInvalid(Keys.ConfigKeys.ProjectId, k_InvalidProjectId)) + .Throws(new ConfigValidationException(Keys.ConfigKeys.ProjectId, k_InvalidProjectId, It.IsAny())); + + Assert.ThrowsAsync( + () => m_EconomyService!.DeleteAsync( + "resource_id", k_InvalidProjectId, TestValues.ValidEnvironmentId, CancellationToken.None)); + + m_EconomyApiV2AsyncMock.DefaultApiAsyncObject.Verify( + a => a.DeleteConfigResourceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Test] + public void DeleteResourceAsync_InvalidEnvironmentId_ThrowsConfigValidationException() + { + m_ValidatorObject.Setup( + v => v.ThrowExceptionIfConfigInvalid(Keys.ConfigKeys.EnvironmentId, k_InvalidEnvironmentId)) + .Throws(new ConfigValidationException(Keys.ConfigKeys.EnvironmentId, k_InvalidEnvironmentId, It.IsAny())); + + Assert.ThrowsAsync( + () => m_EconomyService!.DeleteAsync( + "resource_id", TestValues.ValidProjectId, k_InvalidEnvironmentId, CancellationToken.None)); + + m_EconomyApiV2AsyncMock.DefaultApiAsyncObject.Verify( + a => a.DeleteConfigResourceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + // Add resource tests ----------------------------------------------------- + + [Test] + public async Task AddResourceAsync_WithValidInput_AddsResource() + { + string mockErrorMsg; + m_ValidatorObject.Setup(v => v.IsConfigValid(It.IsAny(), It.IsAny(), out mockErrorMsg)) + .Returns(true); + + CurrencyItemRequest currencyRequest = new CurrencyItemRequest("id", "name", CurrencyItemRequest.TypeEnum.CURRENCY); + AddConfigResourceRequest addRequest = new AddConfigResourceRequest(currencyRequest); + await m_EconomyService!.AddAsync(addRequest, TestValues.ValidProjectId, TestValues.ValidEnvironmentId, CancellationToken.None); + + m_EconomyApiV2AsyncMock.DefaultApiAsyncObject.Verify( + a => a.AddConfigResourceAsync( + TestValues.ValidProjectId, + Guid.Parse(TestValues.ValidEnvironmentId), + addRequest, + 0, + CancellationToken.None), + Times.Once); + } + + [Test] + public void AddResourceAsync_InvalidProjectId_ThrowsConfigValidationException() + { + m_ValidatorObject.Setup(v => v.ThrowExceptionIfConfigInvalid(Keys.ConfigKeys.ProjectId, k_InvalidProjectId)) + .Throws(new ConfigValidationException(Keys.ConfigKeys.ProjectId, k_InvalidProjectId, It.IsAny())); + + CurrencyItemRequest currencyRequest = new CurrencyItemRequest("id", "name", CurrencyItemRequest.TypeEnum.CURRENCY); + AddConfigResourceRequest addRequest = new AddConfigResourceRequest(currencyRequest); + Assert.ThrowsAsync( + () => m_EconomyService!.AddAsync( + addRequest, k_InvalidProjectId, TestValues.ValidEnvironmentId, CancellationToken.None)); + + m_EconomyApiV2AsyncMock.DefaultApiAsyncObject.Verify( + a => a.AddConfigResourceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Test] + public void AddResourceAsync_InvalidEnvironmentId_ThrowsConfigValidationException() + { + m_ValidatorObject.Setup( + v => v.ThrowExceptionIfConfigInvalid(Keys.ConfigKeys.EnvironmentId, k_InvalidEnvironmentId)) + .Throws(new ConfigValidationException(Keys.ConfigKeys.EnvironmentId, k_InvalidEnvironmentId, It.IsAny())); + + CurrencyItemRequest currencyRequest = new CurrencyItemRequest("id", "name", CurrencyItemRequest.TypeEnum.CURRENCY); + AddConfigResourceRequest addRequest = new AddConfigResourceRequest(currencyRequest); + Assert.ThrowsAsync( + () => m_EconomyService!.AddAsync( + addRequest, TestValues.ValidProjectId, k_InvalidEnvironmentId, CancellationToken.None)); + + m_EconomyApiV2AsyncMock.DefaultApiAsyncObject.Verify( + a => a.AddConfigResourceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + // Edit resource tests ----------------------------------------------------- + + [Test] + public async Task EditResourceAsync_WithValidInput_AddsResource() + { + string mockErrorMsg; + m_ValidatorObject.Setup(v => v.IsConfigValid(It.IsAny(), It.IsAny(), out mockErrorMsg)) + .Returns(true); + + CurrencyItemRequest currencyRequest = new CurrencyItemRequest("id", "name", CurrencyItemRequest.TypeEnum.CURRENCY); + AddConfigResourceRequest addRequest = new AddConfigResourceRequest(currencyRequest); + await m_EconomyService!.EditAsync("id", addRequest, TestValues.ValidProjectId, TestValues.ValidEnvironmentId, CancellationToken.None); + + m_EconomyApiV2AsyncMock.DefaultApiAsyncObject.Verify( + a => a.EditConfigResourceAsync( + TestValues.ValidProjectId, + Guid.Parse(TestValues.ValidEnvironmentId), + "id", + addRequest, + 0, + CancellationToken.None), + Times.Once); + } + + [Test] + public void EditResourceAsync_InvalidProjectId_ThrowsConfigValidationException() + { + m_ValidatorObject.Setup(v => v.ThrowExceptionIfConfigInvalid(Keys.ConfigKeys.ProjectId, k_InvalidProjectId)) + .Throws(new ConfigValidationException(Keys.ConfigKeys.ProjectId, k_InvalidProjectId, It.IsAny())); + + CurrencyItemRequest currencyRequest = new CurrencyItemRequest("id", "name", CurrencyItemRequest.TypeEnum.CURRENCY); + AddConfigResourceRequest addRequest = new AddConfigResourceRequest(currencyRequest); + Assert.ThrowsAsync( + () => m_EconomyService!.EditAsync( + "id", addRequest, k_InvalidProjectId, TestValues.ValidEnvironmentId, CancellationToken.None)); + + m_EconomyApiV2AsyncMock.DefaultApiAsyncObject.Verify( + a => a.EditConfigResourceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Test] + public void EditResourceAsync_InvalidEnvironmentId_ThrowsConfigValidationException() + { + m_ValidatorObject.Setup( + v => v.ThrowExceptionIfConfigInvalid(Keys.ConfigKeys.EnvironmentId, k_InvalidEnvironmentId)) + .Throws(new ConfigValidationException(Keys.ConfigKeys.EnvironmentId, k_InvalidEnvironmentId, It.IsAny())); + + CurrencyItemRequest currencyRequest = new CurrencyItemRequest("id", "name", CurrencyItemRequest.TypeEnum.CURRENCY); + AddConfigResourceRequest addRequest = new AddConfigResourceRequest(currencyRequest); + Assert.ThrowsAsync( + () => m_EconomyService!.EditAsync( + "id", addRequest, TestValues.ValidProjectId, k_InvalidEnvironmentId, CancellationToken.None)); + + m_EconomyApiV2AsyncMock.DefaultApiAsyncObject.Verify( + a => a.EditConfigResourceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Economy.UnitTest/Unity.Services.Cli.Economy.UnitTest.csproj b/Unity.Services.Cli/Unity.Services.Cli.Economy.UnitTest/Unity.Services.Cli.Economy.UnitTest.csproj new file mode 100644 index 0000000..d7ca775 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Economy.UnitTest/Unity.Services.Cli.Economy.UnitTest.csproj @@ -0,0 +1,24 @@ + + + net6.0 + enable + false + true + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + \ No newline at end of file diff --git a/Unity.Services.Cli/Unity.Services.Cli.Economy.UnitTest/Utils/TestValues.cs b/Unity.Services.Cli/Unity.Services.Cli.Economy.UnitTest/Utils/TestValues.cs new file mode 100644 index 0000000..8492da0 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Economy.UnitTest/Utils/TestValues.cs @@ -0,0 +1,8 @@ +namespace Unity.Services.Cli.Economy.UnitTest.Utils; + +static class TestValues +{ + public const string ValidProjectId = "42a3e924-4bd9-41f8-adc9-59b18a032599"; + + public const string ValidEnvironmentId = "313b9230-3d52-4695-ac8f-cf9b49cf61fe"; +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Economy/AssemblyInfo.cs b/Unity.Services.Cli/Unity.Services.Cli.Economy/AssemblyInfo.cs new file mode 100644 index 0000000..298fb3b --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Economy/AssemblyInfo.cs @@ -0,0 +1,4 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Unity.Services.Cli.Economy.UnitTest")] +[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] diff --git a/Unity.Services.Cli/Unity.Services.Cli.Economy/Authoring/Constants.cs b/Unity.Services.Cli/Unity.Services.Cli.Economy/Authoring/Constants.cs new file mode 100644 index 0000000..bd814e7 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Economy/Authoring/Constants.cs @@ -0,0 +1,7 @@ +namespace Unity.Services.Cli.Economy.Authoring; + +static class Constants +{ + public const string ServiceType = "Economy"; + public const string ServiceName = "economy"; +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Economy/Authoring/Deploy/CliEconomyDeploymentHandler.cs b/Unity.Services.Cli/Unity.Services.Cli.Economy/Authoring/Deploy/CliEconomyDeploymentHandler.cs new file mode 100644 index 0000000..1159136 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Economy/Authoring/Deploy/CliEconomyDeploymentHandler.cs @@ -0,0 +1,41 @@ +using Newtonsoft.Json; +using Unity.Services.DeploymentApi.Editor; +using Unity.Services.Economy.Editor.Authoring.Core.Deploy; +using Unity.Services.Economy.Editor.Authoring.Core.Logging; +using Unity.Services.Economy.Editor.Authoring.Core.Model; +using Unity.Services.Economy.Editor.Authoring.Core.Service; + +namespace Unity.Services.Cli.Economy.Authoring.Deploy; + +class CliEconomyDeploymentHandler : EconomyDeploymentHandler +{ + internal CliEconomyDeploymentHandler( + IEconomyClient client, + ILogger logger) + : base(client, logger) { } + + internal override void UpdateResourceProgress(IEconomyResource resource, float progress) + { + ((EconomyResource)resource).Progress = progress; + } + + internal override void HandleException(Exception exception, IEconomyResource resource, DeployResult result) + { + result.Failed.Add(resource); + resource.Status = new DeploymentStatus( + Statuses.FailedToDeploy, + exception.InnerException != null ? exception.InnerException.Message : exception.Message, + SeverityLevel.Error); + } + + internal override bool IsLocalResourceUpToDateWithRemote( + IEconomyResource resource, + List remoteResources) + { + var nbEqualResources = remoteResources + .Where(r => r.Id.Equals(resource.Id)) + .Count(r => JsonConvert.SerializeObject(resource).Equals(JsonConvert.SerializeObject(r))); + + return nbEqualResources > 0; + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Economy/Authoring/Deploy/EconomyDeploymentService.cs b/Unity.Services.Cli/Unity.Services.Cli.Economy/Authoring/Deploy/EconomyDeploymentService.cs new file mode 100644 index 0000000..5427f2e --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Economy/Authoring/Deploy/EconomyDeploymentService.cs @@ -0,0 +1,96 @@ +using Spectre.Console; +using Unity.Services.Cli.Authoring.Input; +using Unity.Services.Cli.Authoring.Model; +using Unity.Services.Cli.Authoring.Service; +using Unity.Services.Economy.Editor.Authoring.Core.Deploy; +using Unity.Services.Economy.Editor.Authoring.Core.Model; +using Unity.Services.Gateway.EconomyApiV2.Generated.Client; +using Statuses = Unity.Services.Cli.Authoring.Model.Statuses; + +namespace Unity.Services.Cli.Economy.Authoring.Deploy; + +class EconomyDeploymentService : IDeploymentService +{ + ICliEconomyClient m_EconomyClient; + IEconomyResourcesLoader m_EconomyResourcesLoader; + IEconomyDeploymentHandler m_DeploymentHandler; + + public EconomyDeploymentService( + ICliEconomyClient economyClient, + IEconomyResourcesLoader economyResourcesLoader, + IEconomyDeploymentHandler deploymentHandler) + { + m_EconomyClient = economyClient; + m_EconomyResourcesLoader = economyResourcesLoader; + m_DeploymentHandler = deploymentHandler; + } + + public string ServiceType => Constants.ServiceType; + public string ServiceName => Constants.ServiceName; + + public IReadOnlyList FileExtensions => new[] + { + EconomyResourcesExtensions.Currency, + EconomyResourcesExtensions.InventoryItem, + EconomyResourcesExtensions.MoneyPurchase, + EconomyResourcesExtensions.VirtualPurchase + }; + + public async Task Deploy( + DeployInput deployInput, + IReadOnlyList filePaths, + string projectId, + string environmentId, + StatusContext? loadingContext, + CancellationToken cancellationToken) + { + DeployResult deployResult = null!; + m_EconomyClient.Initialize(environmentId, projectId, cancellationToken); + var reconcile = deployInput.Reconcile; + var dryRun = deployInput.DryRun; + + var resourceLoadTaskList = filePaths + .Select(path => m_EconomyResourcesLoader.LoadResourceAsync(path, cancellationToken)) + .ToList(); + + await Task.WhenAll(resourceLoadTaskList); + + var resourceList = resourceLoadTaskList + .Select(task => task.Result) + .ToList(); + var validResources = resourceList + .Where(resource => resource.Status.Message != Statuses.FailedToRead) + .ToList(); + var failedResources = resourceList + .Where(resource => resource.Status.Message == Statuses.FailedToRead) + .ToList(); + + loadingContext?.Status($"Deploying {Constants.ServiceType} Files..."); + + try + { + deployResult = await m_DeploymentHandler.DeployAsync( + validResources, + dryRun, + reconcile, + cancellationToken); + } + catch (ApiException) + { + // Ignoring it because this exception should already be logged into the deployment content status + } + + if (deployResult == null || resourceLoadTaskList.Count == 0) + { + return new DeploymentResult(resourceList.ToList()); + } + + return new DeploymentResult( + deployResult.Updated, + deployResult.Deleted, + deployResult.Created, + deployResult.Deployed, + deployResult.Failed.Concat(failedResources).ToList(), + dryRun); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Economy/Authoring/EconomyAuthoringLogger.cs b/Unity.Services.Cli/Unity.Services.Cli.Economy/Authoring/EconomyAuthoringLogger.cs new file mode 100644 index 0000000..30ce2fe --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Economy/Authoring/EconomyAuthoringLogger.cs @@ -0,0 +1,31 @@ +using Microsoft.Extensions.Logging; +namespace Unity.Services.Cli.Economy.Authoring; + +class EconomyAuthoringLogger : Services.Economy.Editor.Authoring.Core.Logging.ILogger +{ + readonly ILogger m_Logger; + + public EconomyAuthoringLogger(ILogger logger) + { + m_Logger = logger; + } + + public void LogError(object message) + { + m_Logger.LogError(message.ToString()); + } + + public void LogWarning(object message) + { + m_Logger.LogWarning(message.ToString()); + } + + public void LogInfo(object message) + { + m_Logger.LogInformation(message.ToString()); + } + + public void LogVerbose(object message) + { + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Economy/Authoring/EconomyClient.cs b/Unity.Services.Cli/Unity.Services.Cli.Economy/Authoring/EconomyClient.cs new file mode 100644 index 0000000..bd77812 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Economy/Authoring/EconomyClient.cs @@ -0,0 +1,176 @@ +using Newtonsoft.Json; +using Unity.Services.Cli.Common.Exceptions; +using Unity.Services.Cli.Economy.Handlers; +using Unity.Services.Cli.Economy.Service; +using Unity.Services.Economy.Editor.Authoring.Core.Model; +using Unity.Services.Gateway.EconomyApiV2.Generated.Model; + +namespace Unity.Services.Cli.Economy.Authoring; + +class EconomyClient : ICliEconomyClient +{ + const string k_RemoteFilePath = "Remote"; + const string k_ResourceNonMatchTypeError = "Error - resource does not match any valid economy resource types"; + + readonly IEconomyService m_EconomyServiceClient; + internal string ProjectId { get; set; } + internal string EnvironmentId { get; set; } + internal CancellationToken CancellationToken { get; set; } + + public EconomyClient( + IEconomyService service, + string projectId = "", + string environmentId = "", + CancellationToken cancellationToken = default) + { + m_EconomyServiceClient = service; + ProjectId = projectId; + EnvironmentId = environmentId; + CancellationToken = cancellationToken; + } + + public void Initialize(string environmentId, string projectId, CancellationToken cancellationToken) + { + EnvironmentId = environmentId; + ProjectId = projectId; + CancellationToken = cancellationToken; + } + + public async Task Update(IEconomyResource economyResource, CancellationToken token = new()) + { + var configRequest = EconomyConfigurationBuilder.ConstructAddConfigResourceRequest(economyResource); + + await m_EconomyServiceClient.EditAsync( + economyResource.Id, + configRequest!, + ProjectId, + EnvironmentId, + token); + } + + public async Task Create(IEconomyResource economyResource, CancellationToken token = new()) + { + var configRequest = EconomyConfigurationBuilder.ConstructAddConfigResourceRequest(economyResource); + + await m_EconomyServiceClient.AddAsync(configRequest!, ProjectId, EnvironmentId, token); + } + + public async Task Delete(string resourceId, CancellationToken token = new()) + { + await m_EconomyServiceClient.DeleteAsync(resourceId, ProjectId, EnvironmentId, token); + } + + public async Task> List(CancellationToken token = new()) + { + var results = + await m_EconomyServiceClient.GetResourcesAsync(ProjectId, EnvironmentId, token); + + return results + .Select(ConstructResource) + .ToList(); + } + + public async Task Publish(CancellationToken token = new()) + { + await m_EconomyServiceClient.PublishAsync(ProjectId, EnvironmentId, token); + } + + static IEconomyResource ConstructResource(GetResourcesResponseResultsInner result) + { + try + { + switch (result.ActualInstance) + { + case CurrencyItemResponse currencyItemResponse: + return ConstructEconomyCurrencyResource(currencyItemResponse); + case InventoryItemResponse inventoryItemResponse: + return ConstructEconomyInventoryItemResource(inventoryItemResponse); + case VirtualPurchaseResourceResponse virtualPurchaseResourceResponse: + return ConstructEconomyVirtualPurchaseResource(virtualPurchaseResourceResponse); + case RealMoneyPurchaseResourceResponse realMoneyPurchaseResourceResponse: + return ConstructEconomyRealMoneyPurchaseResource(realMoneyPurchaseResourceResponse); + default: + throw new JsonSerializationException(k_ResourceNonMatchTypeError); + } + } + catch (JsonSerializationException e) + { + throw new CliException(e.Message, ExitCode.HandledError); + } + catch (JsonReaderException e) + { + throw new CliException(e.Message, ExitCode.HandledError); + } + } + + static IEconomyResource ConstructEconomyInventoryItemResource(InventoryItemResponse resource) + { + return new EconomyInventoryItem(resource.Id) + { + Name = resource.Name, + CustomData = resource.CustomData, + Path = k_RemoteFilePath + }; + } + + static IEconomyResource ConstructEconomyRealMoneyPurchaseResource(RealMoneyPurchaseResourceResponse resource) + { + return new EconomyRealMoneyPurchase(resource.Id) + { + Name = resource.Name, + Rewards = resource.Rewards + .Select( + reward => new RealMoneyReward + { + Amount = reward.Amount, + ResourceId = reward.ResourceId + }) + .ToArray(), + StoreIdentifiers = new StoreIdentifiers() + { + AppleAppStore = resource.StoreIdentifiers.AppleAppStore, + GooglePlayStore = resource.StoreIdentifiers.GooglePlayStore + }, + CustomData = resource.CustomData, + Path = k_RemoteFilePath + }; + } + + static IEconomyResource ConstructEconomyVirtualPurchaseResource(VirtualPurchaseResourceResponse resource) + { + return new EconomyVirtualPurchase(resource.Id) + { + Name = resource.Name, + Costs = resource.Costs + .Select( + cost => new Cost + { + Amount = cost.Amount, + ResourceId = cost.ResourceId + }) + .ToArray(), + Rewards = resource.Rewards + .Select( + reward => new Reward + { + Amount = reward.Amount, + ResourceId = reward.ResourceId + }) + .ToArray(), + CustomData = resource.CustomData, + Path = k_RemoteFilePath + }; + } + + static IEconomyResource ConstructEconomyCurrencyResource(CurrencyItemResponse resource) + { + return new EconomyCurrency(resource.Id) + { + Name = resource.Name, + Initial = resource.Initial, + Max = resource.Max, + CustomData = resource.CustomData, + Path = k_RemoteFilePath + }; + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Economy/Authoring/EconomyResourcesLoader.cs b/Unity.Services.Cli/Unity.Services.Cli.Economy/Authoring/EconomyResourcesLoader.cs new file mode 100644 index 0000000..d9ce08c --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Economy/Authoring/EconomyResourcesLoader.cs @@ -0,0 +1,217 @@ +using Newtonsoft.Json; +using Unity.Services.Cli.Common.Exceptions; +using Unity.Services.Cli.Economy.Authoring.IO; +using Unity.Services.Cli.Economy.Templates; +using Unity.Services.DeploymentApi.Editor; +using Unity.Services.Economy.Editor.Authoring.Core.IO; +using Unity.Services.Economy.Editor.Authoring.Core.Model; +using Statuses = Unity.Services.Cli.Authoring.Model.Statuses; + +namespace Unity.Services.Cli.Economy.Authoring; + +class EconomyResourcesLoader : IEconomyResourcesLoader +{ + readonly IEconomyJsonConverter m_EconomyJsonConverter; + readonly IFileSystem m_FileSystem; + + public EconomyResourcesLoader( + IEconomyJsonConverter economyJsonConverter, + IFileSystem fileSystem) + { + m_EconomyJsonConverter = economyJsonConverter; + m_FileSystem = fileSystem; + } + + public string ConstructResourceFile(IEconomyResource resource) + { + EconomyResourceFile? resourceFile = null; + + var resourceId = Path.GetFileNameWithoutExtension(resource.Path) == resource.Id ? null : resource.Id; + + switch (resource) + { + case EconomyCurrency economyCurrency: + resourceFile = new EconomyCurrencyFile() + { + Id = resourceId, + Name = economyCurrency.Name, + Initial = economyCurrency.Initial, + Max = economyCurrency.Max, + CustomData = economyCurrency.CustomData, + Type = economyCurrency.Type, + }; + break; + case EconomyInventoryItem economyInventoryItem: + resourceFile = new EconomyInventoryItemFile() + { + Id = resourceId, + Name = economyInventoryItem.Name, + CustomData = economyInventoryItem.CustomData, + Type = economyInventoryItem.Type, + }; + break; + case EconomyVirtualPurchase economyVirtualPurchase: + resourceFile = new EconomyVirtualPurchaseFile + { + Id = resourceId, + Name = economyVirtualPurchase.Name, + CustomData = economyVirtualPurchase.CustomData, + Costs = economyVirtualPurchase.Costs, + Rewards = economyVirtualPurchase.Rewards, + Type = economyVirtualPurchase.Type, + }; + break; + case EconomyRealMoneyPurchase economyRealMoneyPurchase: + resourceFile = new EconomyRealMoneyPurchaseFile + { + Id = resourceId, + Name = economyRealMoneyPurchase.Name, + CustomData = economyRealMoneyPurchase.CustomData, + Rewards = economyRealMoneyPurchase.Rewards, + StoreIdentifiers = economyRealMoneyPurchase.StoreIdentifiers, + Type = economyRealMoneyPurchase.Type, + }; + break; + } + + if (resourceFile == null) + { + throw new JsonSerializationException($"Error - {resource.Id} is not a valid resource."); + } + + return m_EconomyJsonConverter.SerializeObject(resourceFile, EconomyResourceFile.GetSerializationSettings()); + } + + public async Task LoadResourceAsync( + string path, + CancellationToken cancellationToken) + { + var fileId = Path.GetFileNameWithoutExtension(path); + + IEconomyResource resource = new EconomyResource(fileId) + { + Path = path, + Name = fileId + }; + + try + { + var fileText = await m_FileSystem.ReadAllText(path, cancellationToken); + var fileExtension = Path.GetExtension(path); + + switch (fileExtension) + { + case EconomyResourcesExtensions.Currency: + resource = LoadEconomyCurrencyResource(path, fileId, fileText); + break; + case EconomyResourcesExtensions.InventoryItem: + resource = LoadEconomyInventoryItemResource(path, fileId, fileText); + break; + case EconomyResourcesExtensions.VirtualPurchase: + resource = LoadEconomyVirtualPurchaseResource(path, fileId, fileText); + break; + case EconomyResourcesExtensions.MoneyPurchase: + resource = LoadEconomyRealMoneyPurchaseResource(path, fileId, fileText); + break; + default: + throw new CliException($"Error - File : {path} - does not match any valid economy resource extension", ExitCode.HandledError); + } + + resource.Status = new DeploymentStatus(Statuses.Loaded, ""); + } + catch (Exception ex) when (ex is JsonException or FileNotFoundException or CliException) + { + resource.Status = new DeploymentStatus(Statuses.FailedToRead, ex.Message, SeverityLevel.Error); + } + + return resource; + } + + IEconomyResource LoadEconomyRealMoneyPurchaseResource(string path, string fileId, string resourceFileText) + { + var economyFile = m_EconomyJsonConverter.DeserializeObject(resourceFileText); + + if (economyFile == null) + { + throw new JsonSerializationException($"Error - File : {path} - does not match valid economy real money purchase resource"); + } + + return new EconomyRealMoneyPurchase(economyFile.Id ?? fileId) + { + Name = economyFile.Name, + Rewards = economyFile.Rewards.Select( + economyFileReward => new RealMoneyReward() + { + ResourceId = economyFileReward.ResourceId, + Amount = economyFileReward.Amount + }).ToArray(), + StoreIdentifiers = new StoreIdentifiers() + { + AppleAppStore = economyFile.StoreIdentifiers?.AppleAppStore, + GooglePlayStore = economyFile.StoreIdentifiers?.GooglePlayStore + }, + CustomData = economyFile.CustomData, + Path = path + }; + } + + IEconomyResource LoadEconomyVirtualPurchaseResource(string path, string fileId, string resourceFileText) + { + var economyFile = m_EconomyJsonConverter.DeserializeObject(resourceFileText); + + if (economyFile == null) + { + throw new JsonSerializationException($"Error - File : {path} - does not match valid economy virtual purchase resource"); + } + + return new EconomyVirtualPurchase(economyFile.Id ?? fileId) + { + Name = economyFile.Name, + Costs = economyFile.Costs.Select( + economyFileCost => new Cost + { + ResourceId = economyFileCost.ResourceId, + Amount = economyFileCost.Amount + }).ToArray(), + Rewards = economyFile.Rewards, + CustomData = economyFile.CustomData, + Path = path + }; + } + + IEconomyResource LoadEconomyInventoryItemResource(string path, string fileId, string resourceFileText) + { + var economyFile = m_EconomyJsonConverter.DeserializeObject(resourceFileText); + + if (economyFile == null) + { + throw new JsonSerializationException($"Error - File : {path} - does not match valid economy inventory item resource"); + } + + return new EconomyInventoryItem(economyFile.Id ?? fileId) + { + Name = economyFile.Name, + CustomData = economyFile.CustomData, + Path = path + }; + } + + IEconomyResource LoadEconomyCurrencyResource(string path, string fileId, string resourceFileText) + { + var economyFile = m_EconomyJsonConverter.DeserializeObject(resourceFileText); + + if (economyFile == null) + { + throw new JsonSerializationException($"Error - File : {path} - does not match valid economy currency resource"); + } + + return new EconomyCurrency(economyFile.Id ?? fileId) + { + Name = economyFile.Name, + Initial = economyFile.Initial, + Max = economyFile.Max, + CustomData = economyFile.CustomData, + Path = path + }; + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Economy/Authoring/Fetch/EconomyFetchService.cs b/Unity.Services.Cli/Unity.Services.Cli.Economy/Authoring/Fetch/EconomyFetchService.cs new file mode 100644 index 0000000..5efc264 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Economy/Authoring/Fetch/EconomyFetchService.cs @@ -0,0 +1,78 @@ +using Spectre.Console; +using Unity.Services.Cli.Authoring.Input; +using Unity.Services.Cli.Authoring.Service; +using Unity.Services.Cli.Common.Utils; +using Unity.Services.Economy.Editor.Authoring.Core.Fetch; +using Unity.Services.Economy.Editor.Authoring.Core.Model; +using FetchResult = Unity.Services.Cli.Authoring.Model.FetchResult; + +namespace Unity.Services.Cli.Economy.Authoring.Fetch; + +class EconomyFetchService : IFetchService +{ + readonly IUnityEnvironment m_UnityEnvironment; + readonly ICliEconomyClient m_Client; + readonly IEconomyResourcesLoader m_EconomyResourcesLoader; + readonly IEconomyFetchHandler m_EconomyFetchHandler; + + public string ServiceType => Constants.ServiceType; + public string ServiceName => Constants.ServiceName; + public IReadOnlyList FileExtensions => new[] + { + EconomyResourcesExtensions.Currency, + EconomyResourcesExtensions.InventoryItem, + EconomyResourcesExtensions.MoneyPurchase, + EconomyResourcesExtensions.VirtualPurchase + }; + + public EconomyFetchService( + IUnityEnvironment unityEnvironment, + ICliEconomyClient economyClient, + IEconomyResourcesLoader resourcesLoader, + IEconomyFetchHandler economyFetchHandler) + { + m_UnityEnvironment = unityEnvironment; + m_Client = economyClient; + m_EconomyResourcesLoader = resourcesLoader; + m_EconomyFetchHandler = economyFetchHandler; + } + + public async Task FetchAsync( + FetchInput input, + IReadOnlyList filePaths, + string projectId, + string environmentId, + StatusContext? loadingContext, + CancellationToken cancellationToken) + { + m_Client.Initialize(environmentId, projectId, cancellationToken); + loadingContext?.Status($"Reading {ServiceType} files..."); + + var tasks = filePaths + .Select(path => m_EconomyResourcesLoader.LoadResourceAsync(path, cancellationToken)) + .ToList(); + + await Task.WhenAll(tasks); + + var resources = tasks + .Select(task => task.Result) + .ToList(); + + loadingContext?.Status($"Fetching {ServiceType} Files..."); + + var economyFetchResult = await m_EconomyFetchHandler.FetchAsync( + input.Path, + resources, + input.DryRun, + input.Reconcile, + cancellationToken); + + return new FetchResult( + economyFetchResult.Updated, + economyFetchResult.Deleted, + economyFetchResult.Created, + economyFetchResult.Fetched, + economyFetchResult.Failed, + input.DryRun); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Economy/Authoring/ICliEconomyClient.cs b/Unity.Services.Cli/Unity.Services.Cli.Economy/Authoring/ICliEconomyClient.cs new file mode 100644 index 0000000..8c50b71 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Economy/Authoring/ICliEconomyClient.cs @@ -0,0 +1,8 @@ +using Unity.Services.Economy.Editor.Authoring.Core.Service; + +namespace Unity.Services.Cli.Economy.Authoring; + +interface ICliEconomyClient : IEconomyClient +{ + void Initialize(string environmentId, string projectId, CancellationToken cancellationToken); +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Economy/Authoring/IO/EconomyJsonConverter.cs b/Unity.Services.Cli/Unity.Services.Cli.Economy/Authoring/IO/EconomyJsonConverter.cs new file mode 100644 index 0000000..c8b1cb5 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Economy/Authoring/IO/EconomyJsonConverter.cs @@ -0,0 +1,16 @@ +using Newtonsoft.Json; + +namespace Unity.Services.Cli.Economy.Authoring.IO; + +class EconomyJsonConverter : IEconomyJsonConverter +{ + public T? DeserializeObject(string value) + { + return JsonConvert.DeserializeObject(value); + } + + public string SerializeObject(object? value, JsonSerializerSettings? settings) + { + return JsonConvert.SerializeObject(value, settings); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Economy/Authoring/IO/FileSystem.cs b/Unity.Services.Cli/Unity.Services.Cli.Economy/Authoring/IO/FileSystem.cs new file mode 100644 index 0000000..6976c58 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Economy/Authoring/IO/FileSystem.cs @@ -0,0 +1,5 @@ +using Unity.Services.Economy.Editor.Authoring.Core.IO; + +namespace Unity.Services.Cli.Economy.Authoring.IO; + +class FileSystem : Common.IO.FileSystem, IFileSystem { } diff --git a/Unity.Services.Cli/Unity.Services.Cli.Economy/Authoring/IO/IEconomyJsonConverter.cs b/Unity.Services.Cli/Unity.Services.Cli.Economy/Authoring/IO/IEconomyJsonConverter.cs new file mode 100644 index 0000000..2ea767f --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Economy/Authoring/IO/IEconomyJsonConverter.cs @@ -0,0 +1,10 @@ +using Newtonsoft.Json; + +namespace Unity.Services.Cli.Economy.Authoring.IO; + +interface IEconomyJsonConverter +{ + public T? DeserializeObject(string value); + + public string SerializeObject(object? value, JsonSerializerSettings? settings); +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Economy/EconomyModule.cs b/Unity.Services.Cli/Unity.Services.Cli.Economy/EconomyModule.cs new file mode 100644 index 0000000..455870d --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Economy/EconomyModule.cs @@ -0,0 +1,166 @@ +using System.CommandLine; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Unity.Services.Cli.Authoring.Service; +using Unity.Services.Cli.Common; +using Unity.Services.Cli.Common.Console; +using Unity.Services.Cli.Common.Input; +using Unity.Services.Cli.Common.Networking; +using Unity.Services.Cli.Common.Utils; +using Unity.Services.Cli.Common.Validator; +using Unity.Services.Cli.Economy.Handlers; +using Unity.Services.Cli.Economy.Input; +using Unity.Services.Cli.Economy.Service; +using Unity.Services.Gateway.EconomyApiV2.Generated.Api; +using Unity.Services.Gateway.EconomyApiV2.Generated.Client; +using Unity.Services.Cli.Authoring.Handlers; +using Unity.Services.Cli.Economy.Authoring; +using Unity.Services.Cli.Economy.Authoring.Deploy; +using Unity.Services.Cli.Economy.Authoring.Fetch; +using Unity.Services.Cli.Economy.Authoring.IO; +using Unity.Services.Cli.Economy.Templates; +using Unity.Services.Economy.Editor.Authoring.Core.Fetch; +using Unity.Services.Economy.Editor.Authoring.Core.Deploy; +using Unity.Services.Economy.Editor.Authoring.Core.IO; +using Unity.Services.Economy.Editor.Authoring.Core.Model; +using Unity.Services.Economy.Editor.Authoring.Core.Service; +using IMicrosoftLogger = Microsoft.Extensions.Logging.ILogger; +using ILogger = Unity.Services.Economy.Editor.Authoring.Core.Logging.ILogger; + +namespace Unity.Services.Cli.Economy; + +public class EconomyModule : ICommandModule +{ + public Command ModuleRootCommand { get; } + internal Command GetResourcesCommand { get; } + internal Command GetPublishedCommand { get; } + internal Command PublishCommand { get; } + internal Command DeleteCommand { get; } + internal Command CurrencyCommand { get; } + internal Command InventoryItemCommand { get; } + internal Command VirtualPurchaseCommand { get; } + internal Command RealMoneyPurchaseCommand { get; } + + public EconomyModule() + { + GetResourcesCommand = new Command( + "get-resources", + "Get Economy resources.") + { + CommonInput.CloudProjectIdOption, + CommonInput.EnvironmentNameOption + }; + GetResourcesCommand.SetHandler(GetResourcesHandler.GetAsync); + + GetPublishedCommand = new Command( + "get-published", + "Get published Economy resources.") + { + CommonInput.CloudProjectIdOption, + CommonInput.EnvironmentNameOption + }; + GetPublishedCommand.SetHandler(GetPublishedHandler.GetAsync); + + PublishCommand = new Command( + "publish", + "Publish your Economy configuration.") + { + CommonInput.CloudProjectIdOption, + CommonInput.EnvironmentNameOption + }; + PublishCommand.SetHandler(PublishHandler.PublishAsync); + + DeleteCommand = new Command( + "delete", + "Delete an Economy resource.") + { + CommonInput.CloudProjectIdOption, + CommonInput.EnvironmentNameOption, + EconomyInput.ResourceIdArgument + }; + DeleteCommand.SetHandler(DeleteHandler.DeleteAsync); + + InventoryItemCommand = new Command("inventory", "Manage inventory configuration") + { + InventoryItemCommand.AddNewFileCommand(Constants.ServiceType, EconomyResourceTypes.InventoryItem) + }; + InventoryItemCommand.AddAlias("i"); + CurrencyCommand = new Command("currency", "Manage currency configuration") + { + InventoryItemCommand.AddNewFileCommand(Constants.ServiceType, EconomyResourceTypes.Currency) + }; + CurrencyCommand.AddAlias("c"); + VirtualPurchaseCommand = new Command("virtual-purchase", "Manage virtual purchase configuration") + { + InventoryItemCommand.AddNewFileCommand(Constants.ServiceType, EconomyResourceTypes.VirtualPurchase) + }; + VirtualPurchaseCommand.AddAlias("vp"); + RealMoneyPurchaseCommand = new Command("real-money-purchase", "Manage real money purchase configuration") + { + InventoryItemCommand.AddNewFileCommand(Constants.ServiceType, EconomyResourceTypes.MoneyPurchase) + }; + RealMoneyPurchaseCommand.AddAlias("rmp"); + + ModuleRootCommand = new("economy", "Manage your Economy configuration.") + { + DeleteCommand, + GetPublishedCommand, + GetResourcesCommand, + PublishCommand, + InventoryItemCommand, + CurrencyCommand, + VirtualPurchaseCommand, + RealMoneyPurchaseCommand + }; + + ModuleRootCommand.AddAlias("ec"); + } + + /// + /// Register service to UGS CLI host builder + /// + public static void RegisterServices(HostBuilderContext hostBuilderContext, IServiceCollection serviceCollection) + { + var config = new Configuration + { + BasePath = EndpointHelper.GetCurrentEndpointFor() + }; + config.DefaultHeaders.SetXClientIdHeader(); + serviceCollection.AddSingleton(new EconomyAdminApi(config)); + + serviceCollection.AddTransient(); + serviceCollection.AddTransient(); + + serviceCollection.AddSingleton(); + // Deploy + + serviceCollection.AddTransient(); + serviceCollection.AddTransient(); + + serviceCollection.AddTransient(); + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(s => s.GetRequiredService()); + serviceCollection.AddSingleton(s => s.GetRequiredService()); + + serviceCollection.AddSingleton( + s => new CliEconomyDeploymentHandler( + s.GetRequiredService(), + s.GetRequiredService() + )); + + serviceCollection.AddSingleton(s => s.GetRequiredService()); + + serviceCollection.AddTransient(); + + serviceCollection.AddSingleton( + s => new EconomyFetchHandler( + s.GetRequiredService(), + s.GetRequiredService(), + s.GetRequiredService() + )); + + serviceCollection.AddSingleton(s => s.GetRequiredService()); + + serviceCollection.AddTransient(); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Economy/Handlers/DeleteHandler.cs b/Unity.Services.Cli/Unity.Services.Cli.Economy/Handlers/DeleteHandler.cs new file mode 100644 index 0000000..5cdf676 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Economy/Handlers/DeleteHandler.cs @@ -0,0 +1,27 @@ +using Microsoft.Extensions.Logging; +using Unity.Services.Cli.Common.Console; +using Unity.Services.Cli.Common.Utils; +using Unity.Services.Cli.Economy.Input; +using Unity.Services.Cli.Economy.Service; + +namespace Unity.Services.Cli.Economy.Handlers; + +static class DeleteHandler +{ + public static async Task DeleteAsync(EconomyInput input, IUnityEnvironment unityEnvironment, IEconomyService economyService, ILogger logger, + ILoadingIndicator loadingIndicator, CancellationToken cancellationToken) + { + await loadingIndicator.StartLoadingAsync("Deleting resource...", _ => + DeleteAsync(input, unityEnvironment, economyService, logger, cancellationToken)); + } + + internal static async Task DeleteAsync(EconomyInput input, IUnityEnvironment unityEnvironment, IEconomyService economyService, + ILogger logger, CancellationToken cancellationToken) + { + var projectId = input.CloudProjectId!; + var environmentId = await unityEnvironment.FetchIdentifierAsync(cancellationToken); + + await economyService.DeleteAsync(input.ResourceId!, projectId, environmentId, cancellationToken); + logger.LogInformation($"{input.ResourceId} deleted."); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Economy/Handlers/EconomyConfigurationBuilder.cs b/Unity.Services.Cli/Unity.Services.Cli.Economy/Handlers/EconomyConfigurationBuilder.cs new file mode 100644 index 0000000..a7d8508 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Economy/Handlers/EconomyConfigurationBuilder.cs @@ -0,0 +1,102 @@ +using Unity.Services.Cli.Economy.Model; +using Unity.Services.Economy.Editor.Authoring.Core.Model; +using Unity.Services.Gateway.EconomyApiV2.Generated.Model; +using RealMoneyReward = Unity.Services.Economy.Editor.Authoring.Core.Model.RealMoneyReward; + +namespace Unity.Services.Cli.Economy.Handlers; + +static class EconomyConfigurationBuilder +{ + internal static AddConfigResourceRequest? ConstructAddConfigResourceRequest(IEconomyResource resource) + { + var baseResource = new Resource( + resource.Id, + resource.Name, + ((EconomyResource)resource).EconomyType, + ((EconomyResource)resource).CustomData); + + switch (resource) + { + case EconomyCurrency economyCurrency: + return AddConfigCurrencyResourceRequest(baseResource, economyCurrency); + case EconomyInventoryItem: + return AddConfigInventoryItemResourceRequest(baseResource); + case EconomyVirtualPurchase economyVirtualPurchase: + return AddConfigVirtualPurchaseResourceRequest(baseResource, economyVirtualPurchase); + case EconomyRealMoneyPurchase economyMoneyPurchase: + return AddConfigRealMoneyPurchaseResourceRequest(baseResource, economyMoneyPurchase); + default: + return null; + } + } + static AddConfigResourceRequest? AddConfigRealMoneyPurchaseResourceRequest(Resource baseResource, EconomyRealMoneyPurchase economyMoneyPurchase) + { + var realMoneyRequest = new RealMoneyPurchaseResourceRequest( + baseResource.Id, + baseResource.Name, + RealMoneyPurchaseResourceRequest.TypeEnum.MONEYPURCHASE, + new RealMoneyPurchaseItemRequestStoreIdentifiers( + economyMoneyPurchase.StoreIdentifiers.AppleAppStore, + economyMoneyPurchase.StoreIdentifiers.GooglePlayStore), + GetRealMoneyPurchaseRewards(economyMoneyPurchase.Rewards), + baseResource.CustomData); + return new AddConfigResourceRequest(realMoneyRequest); + } + + static AddConfigResourceRequest? AddConfigVirtualPurchaseResourceRequest(Resource baseResource, EconomyVirtualPurchase economyVirtualPurchase) + { + var virtualPurchaseRequest = new VirtualPurchaseResourceRequest( + baseResource.Id, + baseResource.Name, + VirtualPurchaseResourceRequest.TypeEnum.VIRTUALPURCHASE, + GetVirtualPurchaseCosts(economyVirtualPurchase.Costs), + GetVirtualPurchaseRewards(economyVirtualPurchase.Rewards), + baseResource.CustomData); + return new AddConfigResourceRequest(virtualPurchaseRequest); + } + + static AddConfigResourceRequest? AddConfigInventoryItemResourceRequest(Resource baseResource) + { + var inventoryItemRequest = new InventoryItemRequest( + baseResource.Id, + baseResource.Name, + InventoryItemRequest.TypeEnum.INVENTORYITEM, + baseResource.CustomData); + return new AddConfigResourceRequest(inventoryItemRequest); + } + + static AddConfigResourceRequest? AddConfigCurrencyResourceRequest(Resource baseResource, EconomyCurrency economyCurrency) + { + var currencyRequest = new CurrencyItemRequest( + baseResource.Id, + baseResource.Name, + CurrencyItemRequest.TypeEnum.CURRENCY, + economyCurrency.Initial, + economyCurrency.Max ?? 0, + baseResource.CustomData); + return new AddConfigResourceRequest(currencyRequest); + } + + static List GetVirtualPurchaseCosts(Unity.Services.Economy.Editor.Authoring.Core.Model.Cost[] costs) + { + return costs + .Select(cost => new VirtualPurchaseResourceRequestCostsInner(cost.ResourceId, cost.Amount)) + .ToList(); + } + + static List GetVirtualPurchaseRewards(Reward[] rewards) + { + return rewards + .Select( + reward => + new VirtualPurchaseResourceRequestRewardsInner(reward.ResourceId, reward.Amount, reward.DefaultInstanceData)) + .ToList(); + } + + static List GetRealMoneyPurchaseRewards(RealMoneyReward[] rewards) + { + return rewards + .Select(reward => new RealMoneyPurchaseResourceRequestRewardsInner(reward.ResourceId, reward.Amount)) + .ToList(); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Economy/Handlers/GetPublishedHandler.cs b/Unity.Services.Cli/Unity.Services.Cli.Economy/Handlers/GetPublishedHandler.cs new file mode 100644 index 0000000..b73c929 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Economy/Handlers/GetPublishedHandler.cs @@ -0,0 +1,31 @@ +using Microsoft.Extensions.Logging; +using Unity.Services.Cli.Common.Console; +using Unity.Services.Cli.Common.Input; +using Unity.Services.Cli.Common.Logging; +using Unity.Services.Cli.Common.Utils; +using Unity.Services.Cli.Economy.Model; +using Unity.Services.Cli.Economy.Service; + +namespace Unity.Services.Cli.Economy.Handlers; + +static class GetPublishedHandler +{ + public static async Task GetAsync(CommonInput input, IUnityEnvironment unityEnvironment, IEconomyService economyService, ILogger logger, + ILoadingIndicator loadingIndicator, CancellationToken cancellationToken) + { + await loadingIndicator.StartLoadingAsync("Fetching resources...", _ => + GetAsync(input, unityEnvironment, economyService, logger, cancellationToken)); + } + + internal static async Task GetAsync(CommonInput input, IUnityEnvironment unityEnvironment, IEconomyService economyService, + ILogger logger, CancellationToken cancellationToken) + { + var projectId = input.CloudProjectId!; + var environmentId = await unityEnvironment.FetchIdentifierAsync(cancellationToken); + + var response = await economyService.GetPublishedAsync(projectId, environmentId, cancellationToken); + var result = new EconomyResourcesResponseResult(response); + + logger.LogResultValue(result); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Economy/Handlers/GetResourcesHandler.cs b/Unity.Services.Cli/Unity.Services.Cli.Economy/Handlers/GetResourcesHandler.cs new file mode 100644 index 0000000..c4e1963 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Economy/Handlers/GetResourcesHandler.cs @@ -0,0 +1,31 @@ +using Microsoft.Extensions.Logging; +using Unity.Services.Cli.Common.Console; +using Unity.Services.Cli.Common.Input; +using Unity.Services.Cli.Common.Logging; +using Unity.Services.Cli.Common.Utils; +using Unity.Services.Cli.Economy.Model; +using Unity.Services.Cli.Economy.Service; + +namespace Unity.Services.Cli.Economy.Handlers; + +static class GetResourcesHandler +{ + public static async Task GetAsync(CommonInput input, IUnityEnvironment unityEnvironment, IEconomyService economyService, ILogger logger, + ILoadingIndicator loadingIndicator, CancellationToken cancellationToken) + { + await loadingIndicator.StartLoadingAsync("Fetching resources...", _ => + GetAsync(input, unityEnvironment, economyService, logger, cancellationToken)); + } + + internal static async Task GetAsync(CommonInput input, IUnityEnvironment unityEnvironment, IEconomyService economyService, + ILogger logger, CancellationToken cancellationToken) + { + var projectId = input.CloudProjectId!; + var environmentId = await unityEnvironment.FetchIdentifierAsync(cancellationToken); + + var response = await economyService.GetResourcesAsync(projectId, environmentId, cancellationToken); + var result = new EconomyResourcesResponseResult(response); + + logger.LogResultValue(result); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Economy/Handlers/PublishHandler.cs b/Unity.Services.Cli/Unity.Services.Cli.Economy/Handlers/PublishHandler.cs new file mode 100644 index 0000000..22db2b4 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Economy/Handlers/PublishHandler.cs @@ -0,0 +1,28 @@ +using Microsoft.Extensions.Logging; +using Unity.Services.Cli.Common.Console; +using Unity.Services.Cli.Common.Input; +using Unity.Services.Cli.Common.Utils; +using Unity.Services.Cli.Economy.Service; + +namespace Unity.Services.Cli.Economy.Handlers; + +static class PublishHandler +{ + public static async Task PublishAsync(CommonInput input, IUnityEnvironment unityEnvironment, IEconomyService economyService, ILogger logger, + ILoadingIndicator loadingIndicator, CancellationToken cancellationToken) + { + await loadingIndicator.StartLoadingAsync("Publishing configuration...", _ => + PublishAsync(input, unityEnvironment, economyService, logger, cancellationToken)); + } + + internal static async Task PublishAsync(CommonInput input, IUnityEnvironment unityEnvironment, IEconomyService economyService, + ILogger logger, CancellationToken cancellationToken) + { + var projectId = input.CloudProjectId!; + var environmentId = await unityEnvironment.FetchIdentifierAsync(cancellationToken); + + await economyService.PublishAsync(projectId, environmentId, cancellationToken); + + logger.LogInformation("Publish successful."); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Economy/Input/EconomyInput.cs b/Unity.Services.Cli/Unity.Services.Cli.Economy/Input/EconomyInput.cs new file mode 100644 index 0000000..e5d17d0 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Economy/Input/EconomyInput.cs @@ -0,0 +1,19 @@ +using System.CommandLine; +using Unity.Services.Cli.Common.Input; + +namespace Unity.Services.Cli.Economy.Input; + +class EconomyInput : CommonInput +{ + public static readonly Argument ResourceIdArgument = + new("resource-id", "ID of the Economy resource"); + + [InputBinding(nameof(ResourceIdArgument))] + public string? ResourceId { get; set; } + + public static readonly Argument ResourceFilePathArgument = + new("file-path", "File path of the resource to add"); + + [InputBinding(nameof(ResourceFilePathArgument))] + public string? FilePath { get; set; } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Economy/Model/EconomyResourcesResponseResult.cs b/Unity.Services.Cli/Unity.Services.Cli.Economy/Model/EconomyResourcesResponseResult.cs new file mode 100644 index 0000000..5c7bcea --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Economy/Model/EconomyResourcesResponseResult.cs @@ -0,0 +1,22 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Unity.Services.Gateway.EconomyApiV2.Generated.Model; + +namespace Unity.Services.Cli.Economy.Model; + +class EconomyResourcesResponseResult +{ + public List Resources; + + public EconomyResourcesResponseResult(List resources) + { + Resources = resources; + } + + public override string ToString() + { + var jsonString = JsonConvert.SerializeObject(Resources); + var formattedJson = JToken.Parse(jsonString).ToString(Formatting.Indented); + return formattedJson; + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Economy/Model/Resource.cs b/Unity.Services.Cli/Unity.Services.Cli.Economy/Model/Resource.cs new file mode 100644 index 0000000..dee504d --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Economy/Model/Resource.cs @@ -0,0 +1,19 @@ +using Newtonsoft.Json; + +namespace Unity.Services.Cli.Economy.Model; + +class Resource +{ + [JsonProperty("id")] [JsonRequired] public string Id; + [JsonProperty("name")] [JsonRequired] public string Name; + [JsonProperty("type")] [JsonRequired] public string Type; + [JsonProperty("customData")] public object? CustomData; + + public Resource(string id, string name, string type, object? customData) + { + Id = id; + Name = name; + Type = type; + CustomData = customData; + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Economy/Service/EconomyService.cs b/Unity.Services.Cli/Unity.Services.Cli.Economy/Service/EconomyService.cs new file mode 100644 index 0000000..f8310f9 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Economy/Service/EconomyService.cs @@ -0,0 +1,113 @@ +using System.Net; +using Unity.Services.Cli.Common.Exceptions; +using Unity.Services.Cli.Common.Models; +using Unity.Services.Cli.Common.Validator; +using Unity.Services.Cli.ServiceAccountAuthentication; +using Unity.Services.Cli.ServiceAccountAuthentication.Token; +using Unity.Services.Gateway.EconomyApiV2.Generated.Api; +using Unity.Services.Gateway.EconomyApiV2.Generated.Client; +using Unity.Services.Gateway.EconomyApiV2.Generated.Model; + +namespace Unity.Services.Cli.Economy.Service; + +class EconomyService : IEconomyService +{ + readonly IEconomyAdminApiAsync m_EconomyApiAsync; + readonly IServiceAccountAuthenticationService m_AuthenticationService; + readonly IConfigurationValidator m_ConfigValidator; + + public EconomyService(IEconomyAdminApiAsync defaultEconomyApiAsync, IConfigurationValidator validator, IServiceAccountAuthenticationService authenticationService) + { + m_EconomyApiAsync = defaultEconomyApiAsync; + m_ConfigValidator = validator; + m_AuthenticationService = authenticationService; + } + + internal async Task AuthorizeService(CancellationToken cancellationToken = default) + { + var token = await m_AuthenticationService.GetAccessTokenAsync(cancellationToken); + m_EconomyApiAsync.Configuration.DefaultHeaders.SetAccessTokenHeader(token); + } + + public async Task> GetResourcesAsync(string projectId, string environmentId, + CancellationToken cancellationToken = default) + { + await AuthorizeService(cancellationToken); + m_ConfigValidator.ThrowExceptionIfConfigInvalid(Keys.ConfigKeys.ProjectId, projectId); + m_ConfigValidator.ThrowExceptionIfConfigInvalid(Keys.ConfigKeys.EnvironmentId, environmentId); + + var response = await m_EconomyApiAsync.GetResourcesAsync(projectId, Guid.Parse(environmentId), cancellationToken: cancellationToken); + + if (response == null) + { + throw new ApiException(ExitCode.HandledError, "Issue getting remote resources. Note: Maximum value for currencyes is Int32.Max"); + } + + return response.Results; + } + + public async Task> GetPublishedAsync(string projectId, string environmentId, + CancellationToken cancellationToken = default) + { + await AuthorizeService(cancellationToken); + m_ConfigValidator.ThrowExceptionIfConfigInvalid(Keys.ConfigKeys.ProjectId, projectId); + m_ConfigValidator.ThrowExceptionIfConfigInvalid(Keys.ConfigKeys.EnvironmentId, environmentId); + + try + { + var response = await m_EconomyApiAsync.GetPublishedResourcesAsync(projectId, Guid.Parse(environmentId), + cancellationToken: cancellationToken); + return response.Results; + } + catch (ApiException e) + { + // If you haven't published before, the service returns a 404 and we can return an empty list. + if ((HttpStatusCode)e.ErrorCode == HttpStatusCode.NotFound) + { + return new List(); + } + + throw new ApiException(e.ErrorCode, e.Message); + } + } + + public async Task PublishAsync(string projectId, string environmentId, + CancellationToken cancellationToken = default) + { + await AuthorizeService(cancellationToken); + m_ConfigValidator.ThrowExceptionIfConfigInvalid(Keys.ConfigKeys.ProjectId, projectId); + m_ConfigValidator.ThrowExceptionIfConfigInvalid(Keys.ConfigKeys.EnvironmentId, environmentId); + + await m_EconomyApiAsync.PublishEconomyAsync(projectId, Guid.Parse(environmentId), new PublishBody(true), cancellationToken: cancellationToken); + } + + public async Task DeleteAsync(string resourceId, string projectId, string environmentId, + CancellationToken cancellationToken = default) + { + await AuthorizeService(cancellationToken); + m_ConfigValidator.ThrowExceptionIfConfigInvalid(Keys.ConfigKeys.ProjectId, projectId); + m_ConfigValidator.ThrowExceptionIfConfigInvalid(Keys.ConfigKeys.EnvironmentId, environmentId); + + await m_EconomyApiAsync.DeleteConfigResourceAsync(projectId, Guid.Parse(environmentId), resourceId, cancellationToken: cancellationToken ); + } + + public async Task AddAsync(AddConfigResourceRequest request, string projectId, string environmentId, + CancellationToken cancellationToken = default) + { + await AuthorizeService(cancellationToken); + m_ConfigValidator.ThrowExceptionIfConfigInvalid(Keys.ConfigKeys.ProjectId, projectId); + m_ConfigValidator.ThrowExceptionIfConfigInvalid(Keys.ConfigKeys.EnvironmentId, environmentId); + + await m_EconomyApiAsync.AddConfigResourceAsync(projectId, Guid.Parse(environmentId), request, cancellationToken: cancellationToken ); + } + + public async Task EditAsync(string resourceId, AddConfigResourceRequest request, string projectId, string environmentId, + CancellationToken cancellationToken = default) + { + await AuthorizeService(cancellationToken); + m_ConfigValidator.ThrowExceptionIfConfigInvalid(Keys.ConfigKeys.ProjectId, projectId); + m_ConfigValidator.ThrowExceptionIfConfigInvalid(Keys.ConfigKeys.EnvironmentId, environmentId); + + await m_EconomyApiAsync.EditConfigResourceAsync(projectId, Guid.Parse(environmentId), resourceId, request, cancellationToken: cancellationToken ); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Economy/Service/IEconomyService.cs b/Unity.Services.Cli/Unity.Services.Cli.Economy/Service/IEconomyService.cs new file mode 100644 index 0000000..c68e978 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Economy/Service/IEconomyService.cs @@ -0,0 +1,24 @@ +using Unity.Services.Gateway.EconomyApiV2.Generated.Model; + +namespace Unity.Services.Cli.Economy.Service; + +interface IEconomyService +{ + public Task> GetResourcesAsync(string projectId, string environmentId, + CancellationToken cancellationToken = default); + + public Task> GetPublishedAsync(string projectId, string environmentId, + CancellationToken cancellationToken = default); + + public Task PublishAsync(string projectId, string environmentId, + CancellationToken cancellationToken = default); + + public Task DeleteAsync(string resourceId, string projectId, string environmentId, + CancellationToken cancellationToken = default); + + public Task AddAsync(AddConfigResourceRequest request, string projectId, string environmentId, + CancellationToken cancellationToken = default); + + public Task EditAsync(string resourceId, AddConfigResourceRequest request, string projectId, string environmentId, + CancellationToken cancellationToken = default); +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Economy/Templates/EconomyCurrencyFile.cs b/Unity.Services.Cli/Unity.Services.Cli.Economy/Templates/EconomyCurrencyFile.cs new file mode 100644 index 0000000..97cde05 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Economy/Templates/EconomyCurrencyFile.cs @@ -0,0 +1,37 @@ +using System.ComponentModel; +using Newtonsoft.Json; +using Unity.Services.Cli.Authoring.Templates; +using Unity.Services.Economy.Editor.Authoring.Core.Model; + +namespace Unity.Services.Cli.Economy.Templates; + +class EconomyCurrencyFile : EconomyResourceFile, IFileTemplate +{ + [JsonProperty("$schema")] + public string Value = "https://ugs-config-schemas.unity3d.com/v1/economy/economy-currency.schema.json"; + + [DefaultValue(0)] + public long Initial { get; set; } + public long? Max { get; set; } + + [JsonIgnore] + public string Extension => EconomyResourcesExtensions.Currency; + + [JsonIgnore] + public string FileBodyText + { + get + { + var currency = new EconomyCurrencyFile + { + Name = "My Currency", + Initial = 1, + Max = 50 + }; + return JsonConvert.SerializeObject(currency, GetSerializationSettings()); + } + } + + [JsonConstructor] + public EconomyCurrencyFile() : base(EconomyResourceTypes.Currency) { } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Economy/Templates/EconomyInventoryItemFile.cs b/Unity.Services.Cli/Unity.Services.Cli.Economy/Templates/EconomyInventoryItemFile.cs new file mode 100644 index 0000000..cc82c81 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Economy/Templates/EconomyInventoryItemFile.cs @@ -0,0 +1,31 @@ +using Newtonsoft.Json; +using Unity.Services.Cli.Authoring.Templates; +using Unity.Services.Economy.Editor.Authoring.Core.Model; + +namespace Unity.Services.Cli.Economy.Templates; + +class EconomyInventoryItemFile : EconomyResourceFile, IFileTemplate +{ + [JsonProperty("$schema")] + public string Value = "https://ugs-config-schemas.unity3d.com/v1/economy/economy-inventory.schema.json"; + + [JsonIgnore] + public string Extension => EconomyResourcesExtensions.InventoryItem; + + [JsonIgnore] + public string FileBodyText + { + get + { + var inventory = new EconomyInventoryItemFile(); + inventory.Name = "My Item"; + return JsonConvert.SerializeObject(inventory, GetSerializationSettings()); + } + } + + [JsonConstructor] + public EconomyInventoryItemFile() + : base(EconomyResourceTypes.InventoryItem) + { + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Economy/Templates/EconomyRealMoneyPurchaseFile.cs b/Unity.Services.Cli/Unity.Services.Cli.Economy/Templates/EconomyRealMoneyPurchaseFile.cs new file mode 100644 index 0000000..57838d5 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Economy/Templates/EconomyRealMoneyPurchaseFile.cs @@ -0,0 +1,53 @@ +using Newtonsoft.Json; +using Unity.Services.Cli.Authoring.Templates; +using Unity.Services.Economy.Editor.Authoring.Core.Model; + +namespace Unity.Services.Cli.Economy.Templates; + +class EconomyRealMoneyPurchaseFile : EconomyResourceFile, IFileTemplate +{ + [JsonProperty("$schema")] + public string Value = "https://ugs-config-schemas.unity3d.com/v1/economy/economy-real-purchase.schema.json"; + + [JsonRequired] + public StoreIdentifiers StoreIdentifiers; + [JsonRequired] + public RealMoneyReward[] Rewards; + + [JsonIgnore] + public string Extension => EconomyResourcesExtensions.MoneyPurchase; + + [JsonIgnore] + public string FileBodyText + { + get + { + var virtualPurchase = new EconomyRealMoneyPurchaseFile + { + Name = "My Real Money Purchase", + StoreIdentifiers = new() + { + GooglePlayStore = "123" + }, + Rewards = new[] + { + new RealMoneyReward + { + ResourceId = "MY_RESOURCE_ID", + Amount = 6 + } + } + }; + + return JsonConvert.SerializeObject(virtualPurchase, GetSerializationSettings()); + } + } + + [JsonConstructor] + public EconomyRealMoneyPurchaseFile() + : base(EconomyResourceTypes.MoneyPurchase) + { + StoreIdentifiers = new StoreIdentifiers(); + Rewards = Array.Empty(); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Economy/Templates/EconomyResourceFile.cs b/Unity.Services.Cli/Unity.Services.Cli.Economy/Templates/EconomyResourceFile.cs new file mode 100644 index 0000000..2ae120e --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Economy/Templates/EconomyResourceFile.cs @@ -0,0 +1,36 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Newtonsoft.Json.Serialization; + +namespace Unity.Services.Cli.Economy.Templates; + +abstract class EconomyResourceFile : IEconomyResourceFile +{ + public string? Id { get; set; } + [JsonRequired] + public string Name { get; set; } + + [JsonIgnore] //specialized class will set this field, it should not be stored as it is inferred from the file-type + public string Type { get; set; } + public object? CustomData { get; set; } + + protected EconomyResourceFile(string type, string? id = null, string name = "Display Name", object? customData = null) + { + Id = id; + Name = name; + Type = type; + CustomData = customData; + } + + public static JsonSerializerSettings GetSerializationSettings() + { + var settings = new JsonSerializerSettings() + { + Converters = { new StringEnumConverter() }, + Formatting = Formatting.Indented, + DefaultValueHandling = DefaultValueHandling.IgnoreAndPopulate, + ContractResolver = new CamelCasePropertyNamesContractResolver() + }; + return settings; + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Economy/Templates/EconomyVirtualPurchaseFile.cs b/Unity.Services.Cli/Unity.Services.Cli.Economy/Templates/EconomyVirtualPurchaseFile.cs new file mode 100644 index 0000000..45b36b9 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Economy/Templates/EconomyVirtualPurchaseFile.cs @@ -0,0 +1,58 @@ +using Newtonsoft.Json; +using Unity.Services.Cli.Authoring.Templates; +using Unity.Services.Economy.Editor.Authoring.Core.Model; + +namespace Unity.Services.Cli.Economy.Templates; + +class EconomyVirtualPurchaseFile : EconomyResourceFile, IFileTemplate +{ + [JsonProperty("$schema")] + public string Value = "https://ugs-config-schemas.unity3d.com/v1/economy/economy-virtual-purchase.schema.json"; + + [JsonRequired] + public Cost[] Costs; + [JsonRequired] + public Reward[] Rewards; + + [JsonIgnore] + public string Extension => EconomyResourcesExtensions.VirtualPurchase; + + [JsonIgnore] + public string FileBodyText + { + get + { + var virtualPurchase = new EconomyVirtualPurchaseFile + { + Name = "My Virtual Purchase", + Costs = new[] + { + new Cost + { + Amount = 2, + ResourceId = "MY_RESOURCE_ID" + } + }, + Rewards = new[] + { + new Reward + { + ResourceId = "MY_RESOURCE_ID_2", + Amount = 6, + DefaultInstanceData = null + } + } + }; + + return JsonConvert.SerializeObject(virtualPurchase, GetSerializationSettings()); + } + } + + [JsonConstructor] + public EconomyVirtualPurchaseFile() + : base(EconomyResourceTypes.VirtualPurchase) + { + Costs = Array.Empty(); + Rewards = Array.Empty(); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Economy/Templates/IEconomyResourceFile.cs b/Unity.Services.Cli/Unity.Services.Cli.Economy/Templates/IEconomyResourceFile.cs new file mode 100644 index 0000000..ab68719 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Economy/Templates/IEconomyResourceFile.cs @@ -0,0 +1,14 @@ +using Newtonsoft.Json; + +namespace Unity.Services.Cli.Economy.Templates; + +interface IEconomyResourceFile +{ + public string? Id { get; set; } + [JsonRequired] + public string Name { get; set; } + + [JsonIgnore] //specialized class will set this field, it should not be stored as it is inferred from the file-type + public string Type { get; set; } + public object? CustomData { get; set; } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Economy/Unity.Services.Cli.Economy.csproj b/Unity.Services.Cli/Unity.Services.Cli.Economy/Unity.Services.Cli.Economy.csproj new file mode 100644 index 0000000..306576c --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Economy/Unity.Services.Cli.Economy.csproj @@ -0,0 +1,34 @@ + + + net6.0 + 10 + enable + enable + true + + + + **/EconomyJsonConverter.cs, **/FileSystem.cs, **/EconomyResource.cs + + + + + <_Parameter1>$(AssemblyName).UnitTest + + + <_Parameter1>DynamicProxyGenAssembly2 + + + + + + + + + + + + + $(DefineConstants);$(ExtraDefineConstants) + + \ No newline at end of file diff --git a/Unity.Services.Cli/Unity.Services.Cli.Economy/Utils/EconomyResourceTypes.cs b/Unity.Services.Cli/Unity.Services.Cli.Economy/Utils/EconomyResourceTypes.cs new file mode 100644 index 0000000..df78d00 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Economy/Utils/EconomyResourceTypes.cs @@ -0,0 +1,9 @@ +namespace Unity.Services.Cli.Economy.Utils; + +static class EconomyResourceTypes +{ + public const string InventoryItem = "INVENTORY_ITEM"; + public const string Currency = "CURRENCY"; + public const string VirtualPurchase = "VIRTUAL_PURCHASE"; + public const string MoneyPurchase = "MONEY_PURCHASE"; +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/GameServerHostingUnitTestsConstants.cs b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/GameServerHostingUnitTestsConstants.cs index 789ae5d..59e9967 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/GameServerHostingUnitTestsConstants.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/GameServerHostingUnitTestsConstants.cs @@ -37,6 +37,7 @@ public static class GameServerHostingUnitTestsConstants public const string ValidFleetName2 = "Fleet Two"; public const string OsNameLinux = "Linux"; + public const string OsNameFullNameLinux = "Ubuntu (Server) 22.04 LTS"; // Build Configuration specific constants public const long ValidBuildConfigurationId = 1L; @@ -63,12 +64,18 @@ public static class GameServerHostingUnitTestsConstants public const string ValidFleetRegionId = "00000000-0000-0000-aaaa-300000000000"; - public const long ValidServerId = 123456L; - public const long InvalidServerId = 666L; + public const long ValidServerId = 123L; + public const long ValidServerId2 = 456L; + public const long InvalidServerId = 999L; + public const long ValidLocationId = 111111L; + public const string ValidLocationName = "us-west1"; + + // Machine specific constants public const long ValidMachineId = 654321L; public const long InvalidMachineId = 666L; + public const string ValidMachineName = "p-gce-test-2"; - public const long ValidLocationId = 111111L; - public const string ValidLocationName = "us-west1"; + public const string ValidMachineCpuSeriesShortname = "U1.Standard.3"; + public const string ValidMachineCpuType = "Cloud Intel 2nd Gen Scalable"; } diff --git a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Handlers/BuildConfigurationGetHandlerTests.cs b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Handlers/BuildConfigurationGetHandlerTests.cs index 4a55c51..bf3e05c 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Handlers/BuildConfigurationGetHandlerTests.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Handlers/BuildConfigurationGetHandlerTests.cs @@ -126,31 +126,31 @@ public void BuildConfigurationGetAsync_InvalidInputThrowsException() public async Task BuildConfigurationGetAsync_ValidateLoggingOutput() { - var m_BuildConfiguration = new BuildConfiguration( - binaryPath: "/path/to/simple-go-server", - buildID: long.Parse(ValidBuildId), - buildName: ValidBuildName, - commandLine: "simple-go-server", - configuration: new List() - { - new ConfigEntry( + var buildConfiguration = new BuildConfiguration( + binaryPath: "/path/to/simple-go-server", + buildID: long.Parse(ValidBuildId), + buildName: ValidBuildName, + commandLine: "simple-go-server", + configuration: new List() + { + new( id: 0, key: "key", value: "value" ), - }, - cores: 2L, - createdAt: new DateTime(2022, 10, 11), - fleetID: new Guid(ValidFleetId), - fleetName: ValidFleetName, - id: ValidBuildConfigurationId, - memory: 800L, - name: ValidBuildConfigurationName, - queryType: "sqp", - speed: 1200L, - updatedAt: new DateTime(2022, 10, 11), - version: 1L - ); + }, + cores: 2L, + createdAt: new DateTime(2022, 10, 11), + fleetID: new Guid(ValidFleetId), + fleetName: ValidFleetName, + id: ValidBuildConfigurationId, + memory: 800L, + name: ValidBuildConfigurationName, + queryType: "sqp", + speed: 1200L, + updatedAt: new DateTime(2022, 10, 11), + version: 1L + ); BuildConfigurationIdInput input = new() { @@ -167,6 +167,6 @@ await BuildConfigurationGetHandler.BuildConfigurationGetAsync( CancellationToken.None ); - TestsHelper.VerifyLoggerWasCalled(MockLogger, LogLevel.Critical, LoggerExtension.ResultEventId, Times.Once, new BuildConfigurationOutput(m_BuildConfiguration).ToString()); + TestsHelper.VerifyLoggerWasCalled(MockLogger, LogLevel.Critical, LoggerExtension.ResultEventId, Times.Once, new BuildConfigurationOutput(buildConfiguration).ToString()); } } diff --git a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Handlers/BuildCreateVersionFileUploadHandlerTests.cs b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Handlers/BuildCreateVersionFileUploadHandlerTests.cs index 0dd7515..3479e8b 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Handlers/BuildCreateVersionFileUploadHandlerTests.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Handlers/BuildCreateVersionFileUploadHandlerTests.cs @@ -1,4 +1,5 @@ using System.Net; +using SystemFile = System.IO.File; using Microsoft.Extensions.Logging; using Moq; using Moq.Protected; @@ -27,7 +28,7 @@ void SetUpTempFiles() // write a file to the temp directory m_TempFilePath = Path.Combine(m_TempDirectory, BuildWithOneFileFileName); - File.WriteAllText(m_TempFilePath, "file content to upload"); + SystemFile.WriteAllText(m_TempFilePath, "file content to upload"); // emptyDirectory m_TempDirectoryEmpty = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); diff --git a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Handlers/FleetRegionUpdateHandlerTests.cs b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Handlers/FleetRegionUpdateHandlerTests.cs index c634a70..0aa7629 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Handlers/FleetRegionUpdateHandlerTests.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Handlers/FleetRegionUpdateHandlerTests.cs @@ -2,6 +2,7 @@ using Moq; using Spectre.Console; using Unity.Services.Cli.Common.Console; +using Unity.Services.Cli.Common.Exceptions; using Unity.Services.Cli.Common.Logging; using Unity.Services.Cli.GameServerHosting.Exceptions; using Unity.Services.Cli.GameServerHosting.Handlers; @@ -42,7 +43,7 @@ public async Task FleetRegionUpdateAsync_CallsFetchIdentifierAsync() DisabledDeleteTtl = 60, MaxServers = 3, MinAvailableServers = 3, - ScalingEnabled = false, + ScalingEnabled = false.ToString(), ShutdownTtl = 180 }; @@ -57,9 +58,13 @@ await FleetRegionUpdateHandler.FleetRegionUpdateAsync( MockUnityEnvironment.Verify(ex => ex.FetchIdentifierAsync(CancellationToken.None), Times.Once); } - [TestCase(ValidProjectId, ValidFleetId, ValidRegionId, 120, 60, 3, 3, false, 180)] + [TestCase(ValidProjectId, ValidFleetId, ValidRegionId, 120, 60, 3, 3, "false", 180, false, TestName = "Golden path")] + [TestCase(ValidProjectId, ValidFleetId, ValidRegionId, 120, 60, 3, 3, null, 180, true, TestName = "No Scaling Enabled supplied, maxServers and minAvailableServers both > 0")] + [TestCase(ValidProjectId, ValidFleetId, ValidRegionId, 120, 60, 3, 0, null, 180, true, TestName = "No Scaling Enabled supplied, maxServers > 0 minAvailableServers = 0")] + [TestCase(ValidProjectId, ValidFleetId, ValidRegionId, 120, 60, 0, 3, null, 180, true, TestName = "No Scaling Enabled supplied, maxServers = 0 minAvailableServers > 0")] + [TestCase(ValidProjectId, ValidFleetId, ValidRegionId, 120, 60, 0, 0, null, 180, false, TestName = "No Scaling Enabled supplied, maxServers and minAvailableServers both = 0")] public async Task FleetRegionUpdateAsync_CallsUpdateService(string projectId, string fleetId, string regionId, long deleteTtl, - long disabledDeleteTtl, long maxServers, long minAvailableServers, bool scalingEnabled, long shutdownTtl) + long disabledDeleteTtl, long maxServers, long minAvailableServers, string scalingEnabled, long shutdownTtl, bool defaultScalingEnabled) { FleetRegionUpdateInput input = new() { @@ -87,7 +92,7 @@ await FleetRegionUpdateHandler.FleetRegionUpdateAsync( disabledDeleteTtl, maxServers, minAvailableServers, - scalingEnabled, + defaultScalingEnabled, shutdownTtl ); @@ -97,20 +102,21 @@ await FleetRegionUpdateHandler.FleetRegionUpdateAsync( ), Times.Once); } - [TestCase(null, ValidFleetId, ValidRegionId, 120, 60, 3, 3, false, 180, typeof(ArgumentNullException), TestName = "Null Project Id throws ArgumentNullException")] - [TestCase(InvalidProjectId, ValidFleetId, ValidRegionId, 120, 60, 3, 3, false, 180, typeof(HttpRequestException), TestName = "Invalid Project Id throws HttpRequestException")] - [TestCase(ValidProjectId, null, ValidRegionId, 120, 60, 3, 3, false, 180, typeof(MissingInputException), TestName = "Null Fleet Id throws ArgumentNullException")] - [TestCase(ValidProjectId, InvalidFleetId, ValidRegionId, 120, 60, 3, 3, false, 180, typeof(HttpRequestException), TestName = "Invalid Fleet Id throws HttpRequestException")] - [TestCase(ValidProjectId, ValidFleetId, null, 120, 60, 3, 3, false, 180, typeof(MissingInputException), TestName = "Null Region Id throws MissingInputException")] - [TestCase(ValidProjectId, ValidFleetId, InvalidRegionId, 120, 60, 3, 3, false, 180, typeof(HttpRequestException), TestName = "Invalid Region Id throws HttpRequestException")] - [TestCase(ValidProjectId, ValidFleetId, InvalidRegionId, 120, 60, null, 3, false, 180, typeof(MissingInputException), TestName = "Null Max Servers throws MissingInputException")] - [TestCase(ValidProjectId, ValidFleetId, InvalidRegionId, 120, 60, 3, null, false, 180, typeof(MissingInputException), TestName = "Null Min Available Servers throws MissingInputException")] - [TestCase(ValidProjectId, ValidFleetId, InvalidRegionId, 120, 60, 3, 3, null, 180, typeof(MissingInputException), TestName = "Null Scaling Enabled throws MissingInputException")] - [TestCase(ValidProjectId, ValidFleetId, InvalidRegionId, null, 60, 3, 3, false, 180, typeof(MissingInputException), TestName = "Null Delete Ttl throws MissingInputException")] + [TestCase(null, ValidFleetId, ValidRegionId, 120, 60, 3, 3, "false", 180, typeof(ArgumentNullException), TestName = "Null Project Id throws ArgumentNullException")] + [TestCase(InvalidProjectId, ValidFleetId, ValidRegionId, 120, 60, 3, 3, "false", 180, typeof(HttpRequestException), TestName = "Invalid Project Id throws HttpRequestException")] + [TestCase(ValidProjectId, null, ValidRegionId, 120, 60, 3, 3, "false", 180, typeof(MissingInputException), TestName = "Null Fleet Id throws ArgumentNullException")] + [TestCase(ValidProjectId, InvalidFleetId, ValidRegionId, 120, 60, 3, 3, "false", 180, typeof(HttpRequestException), TestName = "Invalid Fleet Id throws HttpRequestException")] + [TestCase(ValidProjectId, ValidFleetId, null, 120, 60, 3, 3, "false", 180, typeof(MissingInputException), TestName = "Null Region Id throws MissingInputException")] + [TestCase(ValidProjectId, ValidFleetId, InvalidRegionId, 120, 60, 3, 3, "false", 180, typeof(CliException), TestName = "Invalid Region Id throws HttpRequestException")] + [TestCase(ValidProjectId, ValidFleetId, InvalidRegionId, 120, 60, null, 3, "false", 180, typeof(CliException), TestName = "Null Max Servers throws MissingInputException")] + [TestCase(ValidProjectId, ValidFleetId, InvalidRegionId, 120, 60, 3, null, "false", 180, typeof(CliException), TestName = "Null Min Available Servers throws MissingInputException")] + [TestCase(ValidProjectId, ValidFleetId, InvalidRegionId, 120, 60, 3, 3, null, 180, typeof(CliException), TestName = "Null Scaling Enabled throws MissingInputException")] + [TestCase(ValidProjectId, ValidFleetId, InvalidRegionId, null, 60, 3, 3, "false", 180, typeof(MissingInputException), TestName = "Null Delete Ttl throws MissingInputException")] [TestCase(ValidProjectId, ValidFleetId, InvalidRegionId, 120, null, 3, 3, null, 180, typeof(MissingInputException), TestName = "Null Disabled Delete Ttl throws MissingInputException")] - [TestCase(ValidProjectId, ValidFleetId, InvalidRegionId, 120, 60, 3, 3, false, null, typeof(MissingInputException), TestName = "Null Shutdown Ttl throws MissingInputException")] + [TestCase(ValidProjectId, ValidFleetId, InvalidRegionId, 120, 60, 3, 3, "false", null, typeof(MissingInputException), TestName = "Null Shutdown Ttl throws MissingInputException")] + [TestCase(ValidProjectId, ValidFleetId, ValidRegionId, 120, 60, 3, 3, "flse", 180, typeof(CliException), TestName = "Mis-formed bool throws CliException")] public Task FleetRegionUpdateAsync_InvalidInputThrowsException(string? projectId, string? fleetId, string? regionId, - long? deleteTtl, long? disabledDeleteTtl, long? maxServers, long? minAvailableServers, bool? scalingEnabled, + long? deleteTtl, long? disabledDeleteTtl, long? maxServers, long? minAvailableServers, string? scalingEnabled, long? shutdownTtl, Type exceptionType) { FleetRegionUpdateInput input = new() diff --git a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Handlers/HandlerCommon.cs b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Handlers/HandlerCommon.cs index 7fed7ac..b9a440d 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Handlers/HandlerCommon.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Handlers/HandlerCommon.cs @@ -14,8 +14,10 @@ class HandlerCommon static Mock? s_AuthenticationServiceObject; protected static readonly Mock MockUnityEnvironment = new(); internal static GameServerHostingBuildsApiV1Mock? BuildsApi; + internal static GameServerHostingFilesApiV1Mock? FilesApi; internal static GameServerHostingFleetsApiV1Mock? FleetsApi; internal static GameServerHostingServersApiV1Mock? ServersApi; + internal static GameServerHostingMachinesApiV1Mock? MachinesApi; internal static GameServerHostingBuildConfigurationsApiV1Mock? BuildConfigurationsApi; protected static GameServerHostingService? GameServerHostingService; @@ -42,6 +44,19 @@ public void SetUp() }; BuildsApi.SetUp(); + FilesApi = new GameServerHostingFilesApiV1Mock + { + ValidProjects = new List + { + Guid.Parse(ValidProjectId) + }, + ValidEnvironments = new List + { + Guid.Parse(ValidEnvironmentId) + } + }; + FilesApi.SetUp(); + FleetsApi = new GameServerHostingFleetsApiV1Mock { ValidProjects = new List @@ -55,6 +70,19 @@ public void SetUp() }; FleetsApi.SetUp(); + MachinesApi = new GameServerHostingMachinesApiV1Mock + { + ValidProjects = new List + { + Guid.Parse(ValidProjectId) + }, + ValidEnvironments = new List + { + Guid.Parse(ValidEnvironmentId) + } + }; + MachinesApi.SetUp(); + ServersApi = new GameServerHostingServersApiV1Mock { ValidProjects = new List @@ -85,7 +113,9 @@ public void SetUp() s_AuthenticationServiceObject.Object, BuildsApi.DefaultBuildsClient.Object, BuildConfigurationsApi.DefaultBuildConfigurationsClient.Object, + FilesApi.DefaultFilesClient.Object, FleetsApi.DefaultFleetsClient.Object, + MachinesApi.DefaultMachinesClient.Object, ServersApi.DefaultServersClient.Object ); diff --git a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Handlers/MachineListHandlerTests.cs b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Handlers/MachineListHandlerTests.cs new file mode 100644 index 0000000..9f99713 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Handlers/MachineListHandlerTests.cs @@ -0,0 +1,125 @@ +using Moq; +using Spectre.Console; +using Unity.Services.Cli.Common.Console; +using Unity.Services.Cli.GameServerHosting.Handlers; +using Unity.Services.Cli.GameServerHosting.Input; +using Machine = Unity.Services.Gateway.GameServerHostingApiV1.Generated.Model.Machine1; + +namespace Unity.Services.Cli.GameServerHosting.UnitTest.Handlers; + +class MachineListHandlerTests : HandlerCommon +{ + [Test] + public async Task MachineListAsync_CallsLoadingIndicatorStartLoading() + { + var mockLoadingIndicator = new Mock(); + + await MachineListHandler.MachineListAsync( + null!, + MockUnityEnvironment.Object, + null!, + null!, + mockLoadingIndicator.Object, + CancellationToken.None); + + mockLoadingIndicator.Verify( + ex => ex.StartLoadingAsync( + It.IsAny(), + It.IsAny>() + ), + Times.Once + ); + } + + [Test] + public async Task MachineListAsync_CallsFetchIdentifierAsync() + { + MachineListInput input = new() + { + CloudProjectId = ValidProjectId, + TargetEnvironmentName = ValidEnvironmentName + }; + + await MachineListHandler.MachineListAsync( + input, + MockUnityEnvironment.Object, + GameServerHostingService!, + MockLogger!.Object, + CancellationToken.None + ); + + MockUnityEnvironment.Verify(ex => ex.FetchIdentifierAsync(CancellationToken.None), Times.Once); + } + + [TestCase( + null, + null, + null, + null)] + [TestCase( + InvalidFleetId, + "CLOUD", + ValidMachineId, + Machine.StatusEnum.ONLINE)] + [TestCase( + ValidFleetId, + "CLOUD", + InvalidMachineId, + Machine.StatusEnum.ONLINE)] + [TestCase( + ValidFleetId, + "CLOUD", + ValidMachineId, + Machine.StatusEnum.ONLINE)] + public async Task MachineListAsync_CallsListService( + string? fleetId, + string hardwareType, + long? partial, + Machine.StatusEnum? status + ) + { + Guid? fleetGuid = fleetId == null ? null : new Guid(fleetId); + + var partialString = partial == null ? null : partial.ToString(); + + var statusString = status == null ? null : status.ToString(); + + MachineListInput input = new() + { + CloudProjectId = ValidProjectId, + TargetEnvironmentName = ValidEnvironmentName, + FleetId = fleetId, + HardwareType = hardwareType, + Partial = partialString, + Status = statusString + }; + + await MachineListHandler.MachineListAsync( + input, + MockUnityEnvironment.Object, + GameServerHostingService!, + MockLogger!.Object, + CancellationToken.None + ); + + MachinesApi!.DefaultMachinesClient.Verify( + api => api.ListMachinesAsync( + new Guid(input.CloudProjectId), + new Guid(ValidEnvironmentId), + null, + null, + null, + null, + null, + fleetGuid, + null, + hardwareType, + partialString, + statusString, + 0, + CancellationToken.None + ), + Times.Once + ); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Handlers/ServerListHandlerTests.cs b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Handlers/ServerListHandlerTests.cs index 83b0b53..c6f214c 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Handlers/ServerListHandlerTests.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Handlers/ServerListHandlerTests.cs @@ -121,6 +121,7 @@ await ServerListHandler.ServerListAsync( null, fleetGuid, null, + null, buildConfigurationIdString, null, partialString, diff --git a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Input/MachineListInputTests.cs b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Input/MachineListInputTests.cs new file mode 100644 index 0000000..c65055d --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Input/MachineListInputTests.cs @@ -0,0 +1,43 @@ +using System.CommandLine; +using Unity.Services.Cli.GameServerHosting.Input; + +namespace Unity.Services.Cli.GameServerHosting.UnitTest.Input; + +public class MachineListInputTests +{ + [TestCase(InvalidUuid, false)] + [TestCase(ValidFleetId, true)] + public void Validate_WithValidUUIDInput_ReturnsTrue(string id, bool validates) + { + var arg = new[] + { + MachineListInput.FleetIdKey, + id + }; + Assert.That(MachineListInput.FleetIdOption.Parse(arg).Errors, validates ? Is.Empty : Is.Not.Empty); + } + + [TestCase("invalid", false)] + [TestCase("CLOUD", true)] + public void Validate_WithValidHardwareTypeInput_ReturnsTrue(string id, bool validates) + { + var arg = new[] + { + MachineListInput.HardwareTypeKey, + id + }; + Assert.That(MachineListInput.HardwareTypeOption.Parse(arg).Errors, validates ? Is.Empty : Is.Not.Empty); + } + + [TestCase("invalid", false)] + [TestCase("ONLINE", true)] + public void Validate_WithValidStatusInput_ReturnsTrue(string status, bool validates) + { + var arg = new[] + { + MachineListInput.StatusKey, + status + }; + Assert.That(MachineListInput.StatusOption.Parse(arg).Errors, validates ? Is.Empty : Is.Not.Empty); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Mocks/GameServerHostingFilesApiV1Mock.cs b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Mocks/GameServerHostingFilesApiV1Mock.cs new file mode 100644 index 0000000..95ddc94 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Mocks/GameServerHostingFilesApiV1Mock.cs @@ -0,0 +1,132 @@ +using Moq; +using Unity.Services.Gateway.GameServerHostingApiV1.Generated.Api; +using Unity.Services.Gateway.GameServerHostingApiV1.Generated.Model; +using File = Unity.Services.Gateway.GameServerHostingApiV1.Generated.Model.File; +using Unity.Services.Gateway.GameServerHostingApiV1.Generated.Client; + +namespace Unity.Services.Cli.GameServerHosting.UnitTest.Mocks; + +public class GameServerHostingFilesApiV1Mock +{ + readonly List m_TestFiles = new() + { + new File( + filename: "server.log", + path: "logs/", + fileSize: 100, + createdAt: new DateTime(2022, 10, 11), + lastModified: new DateTime(2022, 10, 12), + fleet: new FleetDetails( + id: new Guid(ValidFleetId), + name: "Test Fleet" + ), + machine: new Machine( + id: ValidMachineId, + location: "europe-west1" + ), + serverID: ValidServerId + ), + new File( + filename: "error.log", + path: "logs/", + fileSize: 100, + createdAt: new DateTime(2022, 10, 11), + lastModified: new DateTime(2022, 10, 12), + fleet: new FleetDetails( + id: new Guid(ValidFleetId), + name: "Test Fleet" + ), + machine: new Machine( + id: ValidMachineId, + location: "europe-west1" + ), + serverID: ValidServerId2 + ) + }; + + public Mock DefaultFilesClient = new(); + + public List? ValidEnvironments; + + public List? ValidProjects; + public void SetUp() + { + DefaultFilesClient = new Mock(); + DefaultFilesClient.Setup(a => a.Configuration) + .Returns(new Configuration()); + + DefaultFilesClient.Setup( + a => a.ListFilesAsync( + It.IsAny(), // projectId + It.IsAny(), // environmentId + It.IsAny(), + 0, + CancellationToken.None + )) + .Returns( + ( + Guid projectId, + Guid environmentId, + FilesListRequest filesListRequest, + int _, + CancellationToken _ + ) => + { + var validated = ValidateProjectEnvironment(projectId, environmentId); + if (!validated) throw new HttpRequestException(); + + var results = m_TestFiles.AsEnumerable(); + + if (filesListRequest.PathFilter != null) + { + results = results.Where(a => a.Filename.Contains(filesListRequest.PathFilter)); + } + + if (filesListRequest.ServerIds != null) + { + results = results.Where(a => filesListRequest.ServerIds.Contains(a.ServerID)); + } + + if (filesListRequest.ModifiedFrom != null) + { + results = results.Where(a => ValidateDateAfterFromString(a.LastModified, filesListRequest.ModifiedFrom)); + + } + + if (filesListRequest.ModifiedTo != null) + { + results = results.Where(a => ValidateDateBeforeFromString(a.LastModified, filesListRequest.ModifiedTo)); + + } + + if (filesListRequest.Limit > 0) + { + results = results.Take((int)filesListRequest.Limit); + } + + return Task.FromResult(results.ToList()); + } + ); + } + + static bool ValidateDateBeforeFromString(DateTime lastModified, DateTime modifiedTo) + { + // providedDate is before targetDate + if (lastModified < modifiedTo || lastModified == modifiedTo) return true; + return false; + } + + static bool ValidateDateAfterFromString(DateTime lastModified, DateTime modifiedFrom) + { + // providedDate is after targetDate + if (lastModified > modifiedFrom || lastModified == modifiedFrom) return true; + return false; + } + + bool ValidateProjectEnvironment(Guid projectId, Guid environmentId) + { + if (ValidProjects != null && !ValidProjects.Contains(projectId)) return false; + if (ValidEnvironments != null && !ValidEnvironments.Contains(environmentId)) return false; + return true; + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Mocks/GameServerHostingMachinesApiV1Mock.cs b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Mocks/GameServerHostingMachinesApiV1Mock.cs new file mode 100644 index 0000000..579ca8e --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Mocks/GameServerHostingMachinesApiV1Mock.cs @@ -0,0 +1,139 @@ +using Moq; +using Unity.Services.Gateway.GameServerHostingApiV1.Generated.Client; +using Unity.Services.Gateway.GameServerHostingApiV1.Generated.Api; +using Unity.Services.Gateway.GameServerHostingApiV1.Generated.Model; +using Machine = Unity.Services.Gateway.GameServerHostingApiV1.Generated.Model.Machine1; + +namespace Unity.Services.Cli.GameServerHosting.UnitTest.Mocks; + +public class GameServerHostingMachinesApiV1Mock +{ + readonly List m_TestMachines = new() + { + new Machine( + id: ValidMachineId, + ip: "127.0.0.10", + name: ValidMachineName, + locationId: ValidLocationId, + locationName: ValidLocationName, + fleetId: new Guid(ValidFleetId), + fleetName: ValidFleetName, + hardwareType: Machine.HardwareTypeEnum.CLOUD, + osFamily: Machine.OsFamilyEnum.LINUX, + osName: OsNameFullNameLinux, + serversStates: new ServersStates( + allocated: 1, + available: 2, + held: 3, + online: 4, + reserved: 5 + ), + spec: new MachineSpec( + cpuCores: 1, + cpuShortname: ValidMachineCpuSeriesShortname, + cpuSpeed: 1000, + cpuType: ValidMachineCpuType, + memory:100000 + ), + status: Machine.StatusEnum.ONLINE, + deleted: false, + disabled: false + ) + }; + + public Mock DefaultMachinesClient = new(); + + public List? ValidEnvironments; + + public List? ValidProjects; + + public void SetUp() + { + DefaultMachinesClient = new Mock(); + DefaultMachinesClient.Setup(a => a.Configuration) + .Returns(new Configuration()); + + DefaultMachinesClient.Setup( + a => a.ListMachinesAsync( + It.IsAny(), // projectId + It.IsAny(), // environmentId + It.IsAny(), // limit + It.IsAny(), // lastId + It.IsAny(), // lastValue + It.IsAny(), // sortBy + It.IsAny(), // sortDirection + It.IsAny(), // fleetId + It.IsAny(), // locationId + It.IsAny(), // hardwareType + It.IsAny(), // partial + It.IsAny(), // status + 0, + CancellationToken.None + )) + .Returns( + ( + Guid projectId, + Guid environmentId, + string? _, + Guid? _, + string? _, + string? _, + string? _, + Guid? fleetId, + string? _, + string? hardwareType, + string? partial, + string? status, + int _, + CancellationToken _ + ) => + { + var validated = ValidateProjectEnvironment(projectId, environmentId); + if (!validated) throw new HttpRequestException(); + + var results = m_TestMachines.AsEnumerable(); + + if (fleetId != null) + { + results = results.Where(a => a.FleetId == fleetId); + } + + if (hardwareType != null) + { + var validHardwareType = Enum.TryParse(status, out Machine.StatusEnum hardwareTypeEnum); + if (!validHardwareType) throw new HttpRequestException(); + results = results.Where(a => a.Status == hardwareTypeEnum); + } + + if (partial != null) + { + results = results.Where( + a => + { + var id = a.Id.ToString().Contains(partial); + var ip = a.Ip.Contains(partial); + var machine = a.Id.ToString().Contains(partial); + return id || ip || machine; + } + ); + } + + if (status != null) + { + var validStatus = Enum.TryParse(status, out Machine.StatusEnum statusEnum); + if (!validStatus) throw new HttpRequestException(); + results = results.Where(a => a.Status == statusEnum); + } + + return Task.FromResult(results.ToList()); + } + ); + } + + bool ValidateProjectEnvironment(Guid projectId, Guid environmentId) + { + if (ValidProjects != null && !ValidProjects.Contains(projectId)) return false; + if (ValidEnvironments != null && !ValidEnvironments.Contains(environmentId)) return false; + return true; + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Mocks/GameServerHostingServersApiV1Mock.cs b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Mocks/GameServerHostingServersApiV1Mock.cs index 1c5bd99..df91794 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Mocks/GameServerHostingServersApiV1Mock.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Mocks/GameServerHostingServersApiV1Mock.cs @@ -2,6 +2,7 @@ using Unity.Services.Gateway.GameServerHostingApiV1.Generated.Client; using Unity.Services.Gateway.GameServerHostingApiV1.Generated.Api; using Unity.Services.Gateway.GameServerHostingApiV1.Generated.Model; +using Machine = Unity.Services.Gateway.GameServerHostingApiV1.Generated.Model.Machine1; namespace Unity.Services.Cli.GameServerHosting.UnitTest.Mocks; @@ -17,7 +18,12 @@ class GameServerHostingServersApiV1Mock locationID: ValidLocationId, locationName: ValidLocationName, machineName:"", - machineSpec: new MachineSpec1("2020-12-31T12:00:00Z", "2020-01-01T12:00:00Z", "test-cpu"), + machineSpec: new MachineSpec1( + contractEndDate: new DateTime(2020, 12, 31, 12, 0,0, DateTimeKind.Utc), + contractStartDate: new DateTime(2020, 1, 1, 12, 0, 0, DateTimeKind.Utc), + cpuName: "test-cpu", + cpuShortname: "tc" + ), hardwareType: Server.HardwareTypeEnum.CLOUD, fleetID: new Guid(ValidFleetId), fleetName: ValidFleetName, @@ -70,6 +76,7 @@ public void SetUp() It.IsAny(), // sortBy It.IsAny(), // sortDirection It.IsAny(), // fleetId + It.IsAny(), // machineId It.IsAny(), // locationId It.IsAny(), // buildConfigurationId It.IsAny(), // hardwareType @@ -89,6 +96,7 @@ public void SetUp() string? _, Guid? fleetId, string? _, + string? _, string? buildConfigurationId, string? _, string? partial, diff --git a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Model/FleetRegionUpdateOutputTests.cs b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Model/FleetRegionUpdateOutputTests.cs index 4021937..5787757 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Model/FleetRegionUpdateOutputTests.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Model/FleetRegionUpdateOutputTests.cs @@ -18,7 +18,7 @@ public void Setup() minAvailableServers: 3, regionID: Guid.Parse(ValidRegionId), regionName: "RegionName", - scalingEnabled: false, + scalingEnabled: true, shutdownTTL: 180 ); } diff --git a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Model/MachinesItemOutputTests.cs b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Model/MachinesItemOutputTests.cs new file mode 100644 index 0000000..865605a --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Model/MachinesItemOutputTests.cs @@ -0,0 +1,114 @@ +using System.Text; +using Unity.Services.Cli.GameServerHosting.Model; +using Unity.Services.Gateway.GameServerHostingApiV1.Generated.Model; +using Machine = Unity.Services.Gateway.GameServerHostingApiV1.Generated.Model.Machine1; + +namespace Unity.Services.Cli.GameServerHosting.UnitTest.Model; + +[TestFixture] +public class MachinesItemOutputTests +{ + [SetUp] + public void SetUp() + { + m_Machine = new Machine( + id: ValidMachineId, + ip: "127.0.0.10", + name: ValidMachineName, + locationId: ValidLocationId, + locationName: ValidLocationName, + fleetId: new Guid(ValidFleetId), + fleetName: ValidFleetName, + hardwareType: Machine.HardwareTypeEnum.CLOUD, + osFamily: Machine.OsFamilyEnum.LINUX, + osName: OsNameFullNameLinux, + serversStates: new ServersStates( + allocated: 1, + available: 2, + held: 3, + online: 4, + reserved: 5 + ), + spec: new MachineSpec( + cpuCores: 1, + cpuShortname: ValidMachineCpuSeriesShortname, + cpuSpeed: 1000, + cpuType: ValidMachineCpuType, + memory: 1000000 + ), + status: Machine.StatusEnum.ONLINE, + deleted: false, + disabled: false + ); + } + + Machine? m_Machine; + + [Test] + public void ConstructMachinesItemOutputWithValidInput() + { + MachinesItemOutput output = new(m_Machine!); + Assert.Multiple( + () => + { + Assert.That(output.Id, Is.EqualTo(m_Machine!.Id)); + Assert.That(output.Ip, Is.EqualTo(m_Machine!.Ip)); + Assert.That(output.Name, Is.EqualTo(m_Machine!.Name)); + Assert.That(output.LocationId, Is.EqualTo(m_Machine!.LocationId)); + Assert.That(output.LocationName, Is.EqualTo(m_Machine!.LocationName)); + Assert.That(output.FleetId, Is.EqualTo(m_Machine!.FleetId)); + Assert.That(output.FleetName, Is.EqualTo(m_Machine!.FleetName)); + Assert.That(output.HardwareType, Is.EqualTo(m_Machine!.HardwareType)); + Assert.That(output.OsFamily, Is.EqualTo(m_Machine!.OsFamily)); + Assert.That(output.OsName, Is.EqualTo(m_Machine!.OsName)); + Assert.That(output.ServersStates.Allocated, Is.EqualTo(m_Machine!.ServersStates.Allocated)); + Assert.That(output.ServersStates.Available, Is.EqualTo(m_Machine!.ServersStates.Available)); + Assert.That(output.ServersStates.Held, Is.EqualTo(m_Machine!.ServersStates.Held)); + Assert.That(output.ServersStates.Online, Is.EqualTo(m_Machine!.ServersStates.Online)); + Assert.That(output.ServersStates.Reserved, Is.EqualTo(m_Machine!.ServersStates.Reserved)); + Assert.That(output.Spec.CpuCores, Is.EqualTo(m_Machine!.Spec.CpuCores)); + Assert.That(output.Spec.CpuShortname, Is.EqualTo(m_Machine!.Spec.CpuShortname)); + Assert.That(output.Spec.CpuSpeed, Is.EqualTo(m_Machine!.Spec.CpuSpeed)); + Assert.That(output.Spec.CpuType, Is.EqualTo(m_Machine!.Spec.CpuType)); + Assert.That(output.Spec.Memory, Is.EqualTo(m_Machine!.Spec.Memory)); + Assert.That(output.Status, Is.EqualTo(m_Machine!.Status)); + Assert.That(output.Deleted, Is.EqualTo(m_Machine!.Deleted)); + Assert.That(output.Disabled, Is.EqualTo(m_Machine!.Disabled)); + } + ); + } + + [Test] + public void MachineItemOutputToString() + { + MachinesItemOutput output = new(m_Machine!); + var sb = new StringBuilder(); + sb.AppendLine($"id: {m_Machine!.Id}"); + sb.AppendLine($"ip: {m_Machine!.Ip}"); + sb.AppendLine($"name: {m_Machine!.Name}"); + sb.AppendLine($"locationName: {m_Machine!.LocationName}"); + sb.AppendLine($"locationId: {m_Machine!.LocationId}"); + sb.AppendLine($"fleetName: {m_Machine!.FleetName}"); + sb.AppendLine($"fleetId: {m_Machine!.FleetId}"); + sb.AppendLine($"hardwareType: {m_Machine!.HardwareType}"); + sb.AppendLine($"osFamily: {m_Machine!.OsFamily}"); + sb.AppendLine($"osName: {m_Machine!.OsName}"); + sb.AppendLine($"serversStates:"); + sb.AppendLine($" allocated: {m_Machine!.ServersStates.Allocated}"); + sb.AppendLine($" available: {m_Machine!.ServersStates.Available}"); + sb.AppendLine($" held: {m_Machine!.ServersStates.Held}"); + sb.AppendLine($" online: {m_Machine!.ServersStates.Online}"); + sb.AppendLine($" reserved: {m_Machine!.ServersStates.Reserved}"); + sb.AppendLine($"spec:"); + sb.AppendLine($" cpuCores: {m_Machine!.Spec.CpuCores}"); + sb.AppendLine($" cpuShortname: {m_Machine!.Spec.CpuShortname}"); + sb.AppendLine($" cpuSpeed: {m_Machine!.Spec.CpuSpeed}"); + sb.AppendLine($" cpuType: {m_Machine!.Spec.CpuType}"); + sb.AppendLine($" memory: {m_Machine!.Spec.Memory}"); + sb.AppendLine($"status: {m_Machine!.Status}"); + sb.AppendLine("deleted: false"); + sb.AppendLine("disabled: false"); + + Assert.That(output.ToString(), Is.EqualTo(sb.ToString())); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Model/MachinesItemServerStatsOutputTests.cs b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Model/MachinesItemServerStatsOutputTests.cs new file mode 100644 index 0000000..58bbb53 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Model/MachinesItemServerStatsOutputTests.cs @@ -0,0 +1,53 @@ +using System.Text; +using Unity.Services.Cli.GameServerHosting.Model; +using Unity.Services.Gateway.GameServerHostingApiV1.Generated.Model; + +namespace Unity.Services.Cli.GameServerHosting.UnitTest.Model; + +[TestFixture] +public class MachinesItemServerStatsOutputTests +{ + [SetUp] + public void SetUp() + { + m_ServersStates = new ServersStates( + allocated: 1, + available: 2, + online: 3, + reserved: 4, + held: 5 + ); + } + + ServersStates? m_ServersStates; + + [Test] + public void ConstructMachinesItemServerStatsOutputWithValidInput() + { + MachinesItemServerStatsOutput output = new(m_ServersStates!); + Assert.Multiple( + () => + { + Assert.That(output.Allocated, Is.EqualTo(m_ServersStates!.Allocated)); + Assert.That(output.Available, Is.EqualTo(m_ServersStates!.Available)); + Assert.That(output.Held, Is.EqualTo(m_ServersStates!.Held)); + Assert.That(output.Online, Is.EqualTo(m_ServersStates!.Online)); + Assert.That(output.Reserved, Is.EqualTo(m_ServersStates!.Reserved)); + } + ); + } + + [Test] + public void MachinesItemServerStatsOutputToString() + { + MachinesItemServerStatsOutput output = new(m_ServersStates!); + var sb = new StringBuilder(); + sb.AppendLine($"allocated: {m_ServersStates!.Allocated}"); + sb.AppendLine($"available: {m_ServersStates!.Available}"); + sb.AppendLine($"held: {m_ServersStates!.Held}"); + sb.AppendLine($"online: {m_ServersStates!.Online}"); + sb.AppendLine($"reserved: {m_ServersStates!.Reserved}"); + + Assert.That(output.ToString(), Is.EqualTo(sb.ToString())); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Model/MachinesItemSpecOutputTests.cs b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Model/MachinesItemSpecOutputTests.cs new file mode 100644 index 0000000..d3e6310 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Model/MachinesItemSpecOutputTests.cs @@ -0,0 +1,53 @@ +using System.Text; +using Unity.Services.Cli.GameServerHosting.Model; +using Unity.Services.Gateway.GameServerHostingApiV1.Generated.Model; + +namespace Unity.Services.Cli.GameServerHosting.UnitTest.Model; + +[TestFixture] +public class MachinesItemSpecOutputTests +{ + [SetUp] + public void SetUp() + { + m_MachinesItemSpec = new MachineSpec( + 2, + ValidMachineCpuSeriesShortname, + 2000, + ValidMachineCpuType, + 20000000 + ); + } + + MachineSpec? m_MachinesItemSpec; + + [Test] + public void ConstructMachinesItemSpecOutputWithValidInput() + { + MachinesItemSpecOutput output = new(m_MachinesItemSpec!); + Assert.Multiple( + () => + { + Assert.That(output.CpuCores, Is.EqualTo(m_MachinesItemSpec!.CpuCores)); + Assert.That(output.CpuShortname, Is.EqualTo(m_MachinesItemSpec!.CpuShortname)); + Assert.That(output.CpuSpeed, Is.EqualTo(m_MachinesItemSpec!.CpuSpeed)); + Assert.That(output.CpuType, Is.EqualTo(m_MachinesItemSpec!.CpuType)); + Assert.That(output.Memory, Is.EqualTo(m_MachinesItemSpec!.Memory)); + } + ); + } + + [Test] + public void MachinesItemSpecOutputToString() + { + MachinesItemSpecOutput output = new(m_MachinesItemSpec!); + var sb = new StringBuilder(); + sb.AppendLine($"cpuCores: {m_MachinesItemSpec!.CpuCores}"); + sb.AppendLine($"cpuShortname: {m_MachinesItemSpec!.CpuShortname}"); + sb.AppendLine($"cpuSpeed: {m_MachinesItemSpec!.CpuSpeed}"); + sb.AppendLine($"cpuType: {m_MachinesItemSpec!.CpuType}"); + sb.AppendLine($"memory: {m_MachinesItemSpec!.Memory}"); + + Assert.That(output.ToString(), Is.EqualTo(sb.ToString())); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Model/MachinesOutputTests.cs b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Model/MachinesOutputTests.cs new file mode 100644 index 0000000..d5f9b06 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Model/MachinesOutputTests.cs @@ -0,0 +1,124 @@ +using System.Text; +using Unity.Services.Cli.GameServerHosting.Model; +using Unity.Services.Gateway.GameServerHostingApiV1.Generated.Model; +using Machine = Unity.Services.Gateway.GameServerHostingApiV1.Generated.Model.Machine1; + +namespace Unity.Services.Cli.GameServerHosting.UnitTest.Model; + +[TestFixture] +public class MachinesOutputTests +{ + [SetUp] + public void SetUp() + { + m_Machines = new List + { + new ( + id: ValidMachineId, + ip: "127.0.0.1", + name: ValidMachineName, + locationId: ValidLocationId, + locationName: ValidLocationName, + fleetId: new Guid(ValidFleetId), + fleetName: ValidFleetName, + hardwareType: Machine.HardwareTypeEnum.CLOUD, + osFamily: Machine.OsFamilyEnum.LINUX, + osName: OsNameFullNameLinux, + serversStates: new ServersStates( + allocated: 1, + available: 2, + online: 3, + reserved: 4, + held: 5 + ), + spec: new MachineSpec( + cpuCores: 1, + cpuShortname: ValidMachineCpuSeriesShortname, + cpuSpeed: 1000, + cpuType: ValidMachineCpuType, + memory:100000 + ), + status: Machine.StatusEnum.ONLINE, + deleted: false, + disabled: false + ) + }; + } + + List? m_Machines; + + [Test] + public void ConstructMachinesOutputWithValidInput() + { + MachinesOutput output = new(m_Machines!); + Assert.That(output, Has.Count.EqualTo(m_Machines!.Count)); + for (var i = 0; i < output.Count; i++) + { + Assert.Multiple( + () => + { + Assert.That(output[i].Id, Is.EqualTo(m_Machines[i].Id)); + Assert.That(output[i].Ip, Is.EqualTo(m_Machines[i].Ip)); + Assert.That(output[i].Name, Is.EqualTo(m_Machines[i].Name)); + Assert.That(output[i].LocationId, Is.EqualTo(m_Machines[i].LocationId)); + Assert.That(output[i].LocationName, Is.EqualTo(m_Machines[i].LocationName)); + Assert.That(output[i].FleetId, Is.EqualTo(m_Machines[i].FleetId)); + Assert.That(output[i].FleetName, Is.EqualTo(m_Machines[i].FleetName)); + Assert.That(output[i].HardwareType, Is.EqualTo(m_Machines[i].HardwareType)); + Assert.That(output[i].OsFamily, Is.EqualTo(m_Machines[i].OsFamily)); + Assert.That(output[i].OsName, Is.EqualTo(m_Machines[i].OsName)); + Assert.That(output[i].ServersStates.Allocated, Is.EqualTo(m_Machines[i].ServersStates.Allocated)); + Assert.That(output[i].ServersStates.Available, Is.EqualTo(m_Machines[i].ServersStates.Available)); + Assert.That(output[i].ServersStates.Held, Is.EqualTo(m_Machines[i].ServersStates.Held)); + Assert.That(output[i].ServersStates.Online, Is.EqualTo(m_Machines[i].ServersStates.Online)); + Assert.That(output[i].ServersStates.Reserved, Is.EqualTo(m_Machines[i].ServersStates.Reserved)); + Assert.That(output[i].Spec.CpuCores, Is.EqualTo(m_Machines[i].Spec.CpuCores)); + Assert.That(output[i].Spec.CpuShortname, Is.EqualTo(m_Machines[i].Spec.CpuShortname)); + Assert.That(output[i].Spec.CpuSpeed, Is.EqualTo(m_Machines[i].Spec.CpuSpeed)); + Assert.That(output[i].Spec.CpuType, Is.EqualTo(m_Machines[i].Spec.CpuType)); + Assert.That(output[i].Spec.Memory, Is.EqualTo(m_Machines[i].Spec.Memory)); + Assert.That(output[i].Status, Is.EqualTo(m_Machines[i].Status)); + Assert.That(output[i].Deleted, Is.EqualTo(m_Machines[i].Deleted)); + Assert.That(output[i].Disabled, Is.EqualTo(m_Machines[i].Disabled)); + } + ); + } + } + + [Test] + public void MachineOutputToString() + { + MachinesOutput output = new(m_Machines!); + var sb = new StringBuilder(); + foreach (var machine in output) + { + sb.AppendLine($"- id: {machine.Id}"); + sb.AppendLine($" ip: {machine.Ip}"); + sb.AppendLine($" name: {machine.Name}"); + sb.AppendLine($" locationName: {machine.LocationName}"); + sb.AppendLine($" locationId: {machine.LocationId}"); + sb.AppendLine($" fleetName: {machine.FleetName}"); + sb.AppendLine($" fleetId: {machine.FleetId}"); + sb.AppendLine($" hardwareType: {machine.HardwareType}"); + sb.AppendLine($" osFamily: {machine.OsFamily}"); + sb.AppendLine($" osName: {machine.OsName}"); + sb.AppendLine($" serversStates:"); + sb.AppendLine($" allocated: {machine.ServersStates.Allocated}"); + sb.AppendLine($" available: {machine.ServersStates.Available}"); + sb.AppendLine($" held: {machine.ServersStates.Held}"); + sb.AppendLine($" online: {machine.ServersStates.Online}"); + sb.AppendLine($" reserved: {machine.ServersStates.Reserved}"); + sb.AppendLine($" spec:"); + sb.AppendLine($" cpuCores: {machine.Spec.CpuCores}"); + sb.AppendLine($" cpuShortname: {machine.Spec.CpuShortname}"); + sb.AppendLine($" cpuSpeed: {machine.Spec.CpuSpeed}"); + sb.AppendLine($" cpuType: {machine.Spec.CpuType}"); + sb.AppendLine($" memory: {machine.Spec.Memory}"); + sb.AppendLine($" status: {machine.Status}"); + sb.AppendLine(" deleted: false"); + sb.AppendLine(" disabled: false"); + } + + Assert.That(output.ToString(), Is.EqualTo(sb.ToString())); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Model/ServerGetOutputTests.cs b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Model/ServerGetOutputTests.cs index 6928af4..b0f0ef4 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Model/ServerGetOutputTests.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Model/ServerGetOutputTests.cs @@ -26,7 +26,12 @@ public void SetUp() locationID: 3, locationName: "locationName", machineName: "test machine", - machineSpec: new MachineSpec1("2020-12-31T12:00:00Z", "2020-01-01T12:00:00Z", "test-cpu"), + machineSpec: new MachineSpec1( + contractEndDate: new DateTime(2020, 12, 31, 12, 0, 0, DateTimeKind.Utc), + contractStartDate: new DateTime(2020, 1, 1, 12, 0, 0, DateTimeKind.Utc), + cpuName: "test-cpu", + cpuShortname: "tc" + ), machineID: 5, port: 440, status: Server.StatusEnum.READY @@ -36,7 +41,7 @@ public void SetUp() [Test] public void ConstructServerGetOutput() { - ServerGetOutput output = new ServerGetOutput(m_Server!); + var output = new ServerGetOutput(m_Server!); Assert.Multiple(() => { Assert.That(output.BuildConfigurationId, Is.EqualTo(m_Server!.BuildConfigurationID)); diff --git a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Model/ServersItemOutputTests.cs b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Model/ServersItemOutputTests.cs index 401f45b..986ed1f 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Model/ServersItemOutputTests.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Model/ServersItemOutputTests.cs @@ -16,7 +16,12 @@ public void SetUp() port: 9000, machineID: ValidMachineId, machineName: "test machine", - machineSpec: new MachineSpec1("2020-12-31T12:00:00Z", "2020-01-01T12:00:00Z", "test-cpu"), + machineSpec: new MachineSpec1( + contractEndDate: new DateTime(2020, 12, 31, 12, 0, 0, DateTimeKind.Utc), + contractStartDate: new DateTime(2020, 1, 1, 12, 0, 0, DateTimeKind.Utc), + cpuName: "test-cpu", + cpuShortname: "tc" + ), locationID: ValidLocationId, locationName: ValidLocationName, fleetID: new Guid(ValidFleetId), diff --git a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Model/ServersOutputTests.cs b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Model/ServersOutputTests.cs index 33bb61e..9965f95 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Model/ServersOutputTests.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Model/ServersOutputTests.cs @@ -18,7 +18,12 @@ public void SetUp() port: 9000, machineID: ValidMachineId, machineName: "test machine", - machineSpec: new MachineSpec1("2020-12-31T12:00:00Z", "2020-01-01T12:00:00Z", "test-cpu"), + machineSpec: new MachineSpec1( + contractEndDate: new DateTime(2020, 12, 31, 12, 0,0, DateTimeKind.Utc), + contractStartDate: new DateTime(2020, 1, 1, 12, 0, 0, DateTimeKind.Utc), + cpuName: "test-cpu", + cpuShortname: "tc" + ), locationID: ValidLocationId, locationName: ValidLocationName, fleetID: new Guid(ValidFleetId), diff --git a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Service/GameServerHostingServiceTests.cs b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Service/GameServerHostingServiceTests.cs index f9637eb..321b0b2 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Service/GameServerHostingServiceTests.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Service/GameServerHostingServiceTests.cs @@ -18,9 +18,15 @@ public void SetUp() m_BuildsApi = new GameServerHostingBuildsApiV1Mock(); m_BuildsApi.SetUp(); + m_FilesApi = new GameServerHostingFilesApiV1Mock(); + m_FilesApi.SetUp(); + m_FleetsApi = new GameServerHostingFleetsApiV1Mock(); m_FleetsApi.SetUp(); + m_MachinesApi = new GameServerHostingMachinesApiV1Mock(); + m_MachinesApi.SetUp(); + m_ServersApi = new GameServerHostingServersApiV1Mock(); m_ServersApi.SetUp(); @@ -31,14 +37,18 @@ public void SetUp() m_AuthenticationService.Object, m_BuildsApi.DefaultBuildsClient.Object, m_BuildConfigurationsApi.DefaultBuildConfigurationsClient.Object, + m_FilesApi.DefaultFilesClient.Object, m_FleetsApi.DefaultFleetsClient.Object, + m_MachinesApi.DefaultMachinesClient.Object, m_ServersApi.DefaultServersClient.Object ); } Mock? m_AuthenticationService; GameServerHostingBuildsApiV1Mock? m_BuildsApi; + GameServerHostingFilesApiV1Mock? m_FilesApi; GameServerHostingFleetsApiV1Mock? m_FleetsApi; + GameServerHostingMachinesApiV1Mock? m_MachinesApi; GameServerHostingServersApiV1Mock? m_ServersApi; GameServerHostingService? m_GameServerHostingService; GameServerHostingBuildConfigurationsApiV1Mock? m_BuildConfigurationsApi; @@ -54,6 +64,9 @@ public async Task AuthorizeGameServerHostingService() Assert.That( m_BuildsApi!.DefaultBuildsClient.Object.Configuration.DefaultHeaders["Authorization"], Is.EqualTo($"Basic {TestAccessToken}")); + Assert.That( + m_FilesApi!.DefaultFilesClient.Object.Configuration.DefaultHeaders["Authorization"], + Is.EqualTo($"Basic {TestAccessToken}")); Assert.That( m_FleetsApi!.DefaultFleetsClient.Object.Configuration.DefaultHeaders["Authorization"], Is.EqualTo($"Basic {TestAccessToken}")); @@ -63,6 +76,9 @@ public async Task AuthorizeGameServerHostingService() Assert.That( m_BuildConfigurationsApi!.DefaultBuildConfigurationsClient.Object.Configuration.DefaultHeaders["Authorization"], Is.EqualTo($"Basic {TestAccessToken}")); + Assert.That( + m_MachinesApi!.DefaultMachinesClient.Object.Configuration.DefaultHeaders["Authorization"], + Is.EqualTo($"Basic {TestAccessToken}")); }); } } diff --git a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Exceptions/InvalidIdsListException.cs b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Exceptions/InvalidIdsListException.cs new file mode 100644 index 0000000..c692c65 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Exceptions/InvalidIdsListException.cs @@ -0,0 +1,12 @@ +using System.Runtime.Serialization; +using Unity.Services.Cli.Common.Exceptions; + +namespace Unity.Services.Cli.GameServerHosting.Exceptions; + +[Serializable] +public class InvalidDateRangeException : CliException +{ + protected InvalidDateRangeException(SerializationInfo info, StreamingContext context) : base(info, context) { } + public InvalidDateRangeException(string input) + : base($"Invalid date range: '{input}'", Common.Exceptions.ExitCode.HandledError) { } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/GameServerHostingModule.cs b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/GameServerHostingModule.cs index 04a7c84..61e0efd 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/GameServerHostingModule.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/GameServerHostingModule.cs @@ -229,15 +229,15 @@ public GameServerHostingModule() CommonInput.EnvironmentNameOption, CommonInput.CloudProjectIdOption, BuildConfigurationUpdateInput.BuildConfigIdArgument, - BuildConfigurationUpdateInput.BinaryPathOption, - BuildConfigurationUpdateInput.BuildIdOption, - BuildConfigurationUpdateInput.CommandLineOption, - BuildConfigurationUpdateInput.ConfigurationOption, - BuildConfigurationUpdateInput.CoresOption, - BuildConfigurationUpdateInput.MemoryOption, - BuildConfigurationUpdateInput.NameOption, - BuildConfigurationUpdateInput.QueryTypeOption, - BuildConfigurationUpdateInput.SpeedOption, + BuildConfigurationCreateInput.BinaryPathOption, + BuildConfigurationCreateInput.BuildIdOption, + BuildConfigurationCreateInput.CommandLineOption, + BuildConfigurationCreateInput.ConfigurationOption, + BuildConfigurationCreateInput.CoresOption, + BuildConfigurationCreateInput.MemoryOption, + BuildConfigurationCreateInput.NameOption, + BuildConfigurationCreateInput.QueryTypeOption, + BuildConfigurationCreateInput.SpeedOption, }; BuildConfigurationUpdateCommand.SetHandler< BuildConfigurationUpdateInput, @@ -404,8 +404,12 @@ public GameServerHostingModule() CommonInput.CloudProjectIdOption, FleetRegionUpdateInput.FleetIdOption, FleetRegionUpdateInput.RegionIdOption, + FleetRegionUpdateInput.DeleteTtlOption, + FleetRegionUpdateInput.DisabledDeleteTtlOption, + FleetRegionUpdateInput.MaxServersOption, FleetRegionUpdateInput.MinAvailableServersOption, - FleetRegionUpdateInput.MaxServersOption + FleetRegionUpdateInput.ScalingEnabledOption, + FleetRegionUpdateInput.ShutdownTtlOption, }; FleetRegionUpdateCommand.SetHandler< @@ -425,6 +429,30 @@ public GameServerHostingModule() FleetRegionUpdateCommand }; + MachineListCommand = new Command("list", "List Game Server Hosting machines.") + { + CommonInput.EnvironmentNameOption, + CommonInput.CloudProjectIdOption, + MachineListInput.FleetIdOption, + MachineListInput.LocationIdOption, + MachineListInput.HardwareTypeOption, + MachineListInput.PartialOption, + MachineListInput.StatusOption + }; + MachineListCommand.SetHandler< + MachineListInput, + IUnityEnvironment, + IGameServerHostingService, + ILogger, + ILoadingIndicator, + CancellationToken + >(MachineListHandler.MachineListAsync); + + MachineCommand = new Command("machine", "Manage Game Server Hosting machines.") + { + MachineListCommand, + }; + ServerGetCommand = new Command("get", "Get a Game Server Hosting server.") { CommonInput.EnvironmentNameOption, @@ -458,10 +486,11 @@ public GameServerHostingModule() CancellationToken >(ServerListHandler.ServerListAsync); + ServerCommand = new Command("server", "Manage Game Server Hosting servers.") { ServerGetCommand, - ServerListCommand + ServerListCommand, }; ModuleRootCommand = new Command("game-server-hosting", "Manage Game Sever Hosting resources.") @@ -470,8 +499,8 @@ public GameServerHostingModule() BuildConfigurationCommand, FleetCommand, FleetRegionCommand, + MachineCommand, ServerCommand - }; ModuleRootCommand.AddAlias("gsh"); @@ -479,6 +508,7 @@ public GameServerHostingModule() BuildConfigurationCommand.AddAlias("bc"); FleetCommand.AddAlias("f"); FleetRegionCommand.AddAlias("fr"); + MachineCommand.AddAlias("m"); ServerCommand.AddAlias("s"); } @@ -486,6 +516,7 @@ public GameServerHostingModule() internal Command BuildConfigurationCommand { get; } internal Command FleetCommand { get; } internal Command FleetRegionCommand { get; } + internal Command MachineCommand { get; } internal Command ServerCommand { get; } // Build Commands @@ -517,22 +548,24 @@ public GameServerHostingModule() internal Command FleetRegionCreateCommand { get; } internal Command FleetRegionUpdateCommand { get; } + // Machine Commands + internal Command MachineListCommand { get; } + // Server Commands internal Command ServerGetCommand { get; } internal Command ServerListCommand { get; } + internal static ExceptionFactory ExceptionFactory => (method, response) => { - var message = (string text) => $"Error calling {method}: {text}"; - // Handle errors from the backend var statusCode = (int)response.StatusCode; if (statusCode >= 400) { return new ApiException( statusCode, - message(response.RawContent), + Message(response.RawContent), response.RawContent, response.Headers ); @@ -544,13 +577,15 @@ public GameServerHostingModule() { return new ApiException( statusCode, - message(response.ErrorText), + Message(response.ErrorText), response.Content, response.Headers ); } return null!; + + string Message(string text) => $"Error calling {method}: {text}"; }; // GSH Module Command @@ -575,10 +610,18 @@ public static void RegisterServices(HostBuilderContext _, IServiceCollection ser { ExceptionFactory = ExceptionFactory }; + IFilesApi filesApi = new FilesApi(gameServerHostingConfiguration) + { + ExceptionFactory = ExceptionFactory + }; IFleetsApi fleetsApi = new FleetsApi(gameServerHostingConfiguration) { ExceptionFactory = ExceptionFactory }; + IMachinesApi machinesApi = new MachinesApi(gameServerHostingConfiguration) + { + ExceptionFactory = ExceptionFactory + }; IServersApi serversApi = new ServersApi(gameServerHostingConfiguration) { ExceptionFactory = ExceptionFactory @@ -588,7 +631,9 @@ public static void RegisterServices(HostBuilderContext _, IServiceCollection ser authenticationService, buildsApi, buildConfigurationsApi, + filesApi, fleetsApi, + machinesApi, serversApi ); diff --git a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Handlers/BuildCreateVersionFileUploadHandler.cs b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Handlers/BuildCreateVersionFileUploadHandler.cs index 604b2c4..f466aea 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Handlers/BuildCreateVersionFileUploadHandler.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Handlers/BuildCreateVersionFileUploadHandler.cs @@ -1,5 +1,6 @@ using System.Net; using System.Text; +using SystemFile = System.IO.File; using Microsoft.Extensions.Logging; using Polly; using Unity.Services.Cli.Common.Exceptions; @@ -168,7 +169,7 @@ CancellationToken cancellationToken var uploaded = 0; foreach (var fileToUpload in localFiles) { - var localFile = File.OpenRead(fileToUpload.GetSystemPath()); + var localFile = SystemFile.OpenRead(fileToUpload.GetSystemPath()); var remoteFile = await service.BuildsApi.CreateOrUpdateBuildFileAsync( Guid.Parse(projectId), Guid.Parse(environmentId), diff --git a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Handlers/FleetRegionUpdateHandler.cs b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Handlers/FleetRegionUpdateHandler.cs index 001681f..8106909 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Handlers/FleetRegionUpdateHandler.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Handlers/FleetRegionUpdateHandler.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.Logging; using Unity.Services.Cli.Common.Console; +using Unity.Services.Cli.Common.Exceptions; using Unity.Services.Cli.Common.Logging; using Unity.Services.Cli.Common.Utils; using Unity.Services.Cli.GameServerHosting.Exceptions; @@ -39,13 +40,63 @@ CancellationToken cancellationToken var regionId = input.RegionId ?? throw new MissingInputException(FleetRegionUpdateInput.RegionIdKey); var deleteTtl = input.DeleteTtl ?? throw new MissingInputException(FleetRegionUpdateInput.DeleteTtlKey); var disabledDeleteTtl = input.DisabledDeleteTtl ?? throw new MissingInputException(FleetRegionUpdateInput.DisabledDeleteTtlKey); - var maxServers = input.MaxServers ?? throw new MissingInputException(FleetRegionUpdateInput.MaxServersKey); - var minAvailableServers = input.MinAvailableServers ?? throw new MissingInputException(FleetRegionUpdateInput.MinAvailableServersKey); - var scalingEnabled = input.ScalingEnabled ?? throw new MissingInputException(FleetRegionUpdateInput.ScalingEnabledKey); var shutdownTtl = input.ShutdownTtl ?? throw new MissingInputException(FleetRegionUpdateInput.ShutdownTtlKey); + var maxServers = input.MaxServers; + var minServers = input.MinAvailableServers; + var scalingEnabled = input.ScalingEnabled; + await service.AuthorizeGameServerHostingService(cancellationToken); + // Fetch the fleet this fleet region belongs to + var fleet = await service.FleetsApi.GetFleetAsync(Guid.Parse(input.CloudProjectId!), + Guid.Parse(environmentId), fleetId, cancellationToken: cancellationToken); + + var region = fleet.FleetRegions.Find(r => r.RegionID == regionId); + if (region == null) + { + throw new CliException("Region not found", ExitCode.HandledError); + } + + if (scalingEnabled != null) + { + try + { + region.ScalingEnabled = bool.Parse(scalingEnabled); + } + catch (FormatException) + { + throw new CliException("Invalid --scaling-enabled value, please provide a valid boolean (true|false)", ExitCode.HandledError); + } + } + + if (maxServers != null) + { + region.MaxServers = (long)maxServers; + if (scalingEnabled == null) + { + if (region.MaxServers > 0) + { + region.ScalingEnabled = true; + scalingEnabled = region.ScalingEnabled.ToString(); + } + else + { + region.ScalingEnabled = false; + } + } + + } + + if (minServers != null) + { + region.MinAvailableServers = (long)minServers; + if (scalingEnabled == null) + { + region.ScalingEnabled = region.MinAvailableServers > 0; + } + } + var updateFleetRegionResponse = await service.FleetsApi.UpdateFleetRegionAsync( Guid.Parse(input.CloudProjectId!), Guid.Parse(environmentId), @@ -54,9 +105,9 @@ CancellationToken cancellationToken new UpdateRegionRequest( deleteTtl, disabledDeleteTtl, - maxServers, - minAvailableServers, - scalingEnabled, + region.MaxServers, + region.MinAvailableServers, + region.ScalingEnabled, shutdownTtl ), cancellationToken: cancellationToken diff --git a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Handlers/MachineListHandler.cs b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Handlers/MachineListHandler.cs new file mode 100644 index 0000000..929e969 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Handlers/MachineListHandler.cs @@ -0,0 +1,84 @@ +using Microsoft.Extensions.Logging; +using Unity.Services.Cli.Common.Console; +using Unity.Services.Cli.Common.Logging; +using Unity.Services.Cli.Common.Utils; +using Unity.Services.Cli.GameServerHosting.Input; +using Unity.Services.Cli.GameServerHosting.Model; +using Unity.Services.Cli.GameServerHosting.Service; + +namespace Unity.Services.Cli.GameServerHosting.Handlers; + +static class MachineListHandler +{ + public static async Task MachineListAsync( + MachineListInput input, + IUnityEnvironment unityEnvironment, + IGameServerHostingService service, + ILogger logger, + ILoadingIndicator loadingIndicator, + CancellationToken cancellationToken + ) + { + await loadingIndicator.StartLoadingAsync("Fetching machine list...", + _ => MachineListAsync(input, unityEnvironment, service, logger, cancellationToken)); + } + + internal static async Task MachineListAsync( + MachineListInput input, + IUnityEnvironment unityEnvironment, + IGameServerHostingService service, + ILogger logger, + CancellationToken cancellationToken + ) + { + // FetchIdentifierAsync handles null checks for project-id and environment + var environmentId = await unityEnvironment.FetchIdentifierAsync(cancellationToken); + + Guid? fleetId = null; + string? locationId = null; + string? hardwareType = null; + string? partial = null; + string? status = null; + + if (input.FleetId != null) + { + fleetId = Guid.Parse(input.FleetId); + } + + if (input.LocationId != null) + { + locationId = input.LocationId; + } + + if (input.HardwareType != null) + { + hardwareType = input.HardwareType; + } + + if (input.Partial != null) + { + partial = input.Partial; + } + + if (input.Status != null) + { + status = input.Status; + } + + + await service.AuthorizeGameServerHostingService(cancellationToken); + + var machines = await service.MachinesApi.ListMachinesAsync( + Guid.Parse(input.CloudProjectId!), + Guid.Parse(environmentId), + fleetId: fleetId, + locationId: locationId, + hardwareType: hardwareType, + partial: partial, + status: status, + cancellationToken: cancellationToken + ); + + logger.LogResultValue(new MachinesOutput(machines)); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Input/FleetRegionUpdateInput.cs b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Input/FleetRegionUpdateInput.cs index e2c74ae..4ead011 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Input/FleetRegionUpdateInput.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Input/FleetRegionUpdateInput.cs @@ -33,50 +33,35 @@ public class FleetRegionUpdateInput : CommonInput public static readonly Option DeleteTtlOption = new( DeleteTtlKey, "The delete TTL set for the fleet" - ) - { - IsRequired = true - }; + ); public static readonly Option DisabledDeleteTtlOption = new( DisabledDeleteTtlKey, "The disabled delete TTL set for the fleet." - ) - { - IsRequired = true - }; + ); - public static readonly Option MaxServersOption = new( + public static readonly Option MaxServersOption = new( MaxServersKey, "The maximum number of servers to host in the fleet region." - ) - { - IsRequired = true - }; + ); + - public static readonly Option MinAvailableServersOption = new( + public static readonly Option MinAvailableServersOption = new( MinAvailableServersKey, "The minimum number of servers to keep free for new game sessions." - ) - { - IsRequired = true - }; + ); + - public static readonly Option ScalingEnabledOption = new( + public static readonly Option ScalingEnabledOption = new( ScalingEnabledKey, "Whether scaling should be enabled for the fleet." - ) - { - IsRequired = true - }; + ); + public static readonly Option ShutdownTtlOption = new( ShutdownTtlKey, "The shutdown TTL set for the fleet." - ) - { - IsRequired = true - }; + ); [InputBinding(nameof(FleetIdOption))] public Guid? FleetId { get; set; } @@ -97,7 +82,7 @@ public class FleetRegionUpdateInput : CommonInput public long? MinAvailableServers { get; set; } [InputBinding(nameof(ScalingEnabledOption))] - public bool? ScalingEnabled { get; set; } + public string? ScalingEnabled { get; set; } [InputBinding(nameof(ShutdownTtlOption))] public long? ShutdownTtl { get; set; } diff --git a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Input/MachineListInput.cs b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Input/MachineListInput.cs new file mode 100644 index 0000000..5329c50 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Input/MachineListInput.cs @@ -0,0 +1,104 @@ +using System.CommandLine; +using System.CommandLine.Parsing; +using Unity.Services.Cli.Common.Input; +using Machine = Unity.Services.Gateway.GameServerHostingApiV1.Generated.Model.Machine1; + +namespace Unity.Services.Cli.GameServerHosting.Input; + +public class MachineListInput : CommonInput +{ + public const string FleetIdKey = "--fleet-id"; + public const string HardwareTypeKey = "--hardware-type"; + public const string LocationIdKey = "--location-id"; + public const string PartialKey = "--partial"; + public const string StatusKey = "--status"; + + public static readonly Option FleetIdOption = new( + FleetIdKey, + "The fleet ID to filter the machine list by." + ); + + public static readonly Option HardwareTypeOption = new( + HardwareTypeKey, + "The hardware type to filter the machine list by." + ); + + public static readonly Option LocationIdOption = new( + LocationIdKey, + "The location ID to filter the machine list by." + ); + + public static readonly Option PartialOption = new( + PartialKey, + "The partial search to filter the machine list by." + ); + + public static readonly Option StatusOption = new( + StatusKey, + "The status to filter the machine list by." + ); + + static MachineListInput() + { + FleetIdOption.AddValidator(ValidateFleetId); + HardwareTypeOption.AddValidator(ValidateHardwareType); + StatusOption.AddValidator(ValidateStatus); + } + + [InputBinding(nameof(FleetIdOption))] + public string? FleetId { get; set; } + + [InputBinding(nameof(HardwareTypeOption))] + public string? HardwareType { get; set; } + + [InputBinding(nameof(LocationIdOption))] + public string? LocationId { get; set; } + + [InputBinding(nameof(PartialOption))] + public string? Partial { get; set; } + + [InputBinding(nameof(StatusOption))] + public string? Status { get; set; } + + static void ValidateFleetId(OptionResult result) + { + var value = result.GetValueOrDefault(); + if (value == null) return; + try + { + Guid.Parse(value); + } + catch (Exception) + { + result.ErrorMessage = $"Invalid option for --fleet-id. {value} is not a valid UUID"; + } + } + + static void ValidateHardwareType(OptionResult result) + { + var value = result.GetValueOrDefault(); + if (value == null) return; + try + { + Enum.Parse(value); + } + catch (Exception) + { + result.ErrorMessage = $"Invalid option for --hardware-type. Did you mean one of the following? {string.Join(", ", Enum.GetNames())}"; + } + } + + static void ValidateStatus(OptionResult result) + { + var value = result.GetValueOrDefault(); + if (value == null) return; + try + { + Enum.Parse(value); + } + catch (Exception) + { + result.ErrorMessage = $"Invalid option for --status. Did you mean one of the following? {string.Join(", ", Enum.GetNames())}"; + } + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Model/FilesItemFleetOutput.cs b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Model/FilesItemFleetOutput.cs new file mode 100644 index 0000000..a25d1b2 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Model/FilesItemFleetOutput.cs @@ -0,0 +1,27 @@ +using Unity.Services.Gateway.GameServerHostingApiV1.Generated.Model; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace Unity.Services.Cli.GameServerHosting.Model; + +public class FilesItemFleetOutput +{ + public FilesItemFleetOutput(FleetDetails fleetDetails) + { + Id = fleetDetails.Id; + Name = fleetDetails.Name; + } + + public Guid Id { get; } + public string Name { get; } + + + public override string ToString() + { + var serializer = new SerializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .DisableAliases() + .Build(); + return serializer.Serialize(this); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Model/FleetRegionUpdateOutput.cs b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Model/FleetRegionUpdateOutput.cs index 072a4f7..e0d3d77 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Model/FleetRegionUpdateOutput.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Model/FleetRegionUpdateOutput.cs @@ -11,8 +11,8 @@ public FleetRegionUpdateOutput(UpdatedFleetRegion region) DeleteTtl = region.DeleteTTL; DisabledDeleteTtl = region.DisabledDeleteTTL; Id = region.Id; - MaxServers = region.MaxServers; - MinAvailableServers = region.MinAvailableServers; + MaxServers = region.ScalingEnabled ? region.MaxServers : 0; + MinAvailableServers = region.ScalingEnabled ? region.MinAvailableServers : 0; RegionId = region.RegionID; RegionName = region.RegionName; ScalingEnabled = region.ScalingEnabled; diff --git a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Model/MachinesItemOutput.cs b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Model/MachinesItemOutput.cs new file mode 100644 index 0000000..6880c3c --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Model/MachinesItemOutput.cs @@ -0,0 +1,66 @@ +using Machine = Unity.Services.Gateway.GameServerHostingApiV1.Generated.Model.Machine1; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace Unity.Services.Cli.GameServerHosting.Model; + +public class MachinesItemOutput +{ + public MachinesItemOutput(Machine machine) + { + Id = machine.Id; + Ip = machine.Ip; + Name = machine.Name; + LocationId = machine.LocationId; + LocationName = machine.LocationName; + FleetId = machine.FleetId; + FleetName = machine.FleetName; + HardwareType = machine.HardwareType; + OsFamily = machine.OsFamily; + OsName = machine.OsName; + ServersStates = new MachinesItemServerStatsOutput(machine.ServersStates); + Spec = new MachinesItemSpecOutput(machine.Spec); + Status = machine.Status; + Deleted = machine.Deleted; + Disabled = machine.Disabled; + } + + public long Id { get; } + + public string Ip { get; } + + public string Name { get; } + + public string LocationName { get; } + + public long LocationId { get; } + + public string FleetName { get; } + + public Guid FleetId { get; } + + public Machine.HardwareTypeEnum HardwareType { get; } + + public Machine.OsFamilyEnum OsFamily { get; } + + public string OsName { get; } + + public MachinesItemServerStatsOutput ServersStates { get; } + + public MachinesItemSpecOutput Spec { get; } + + public Machine.StatusEnum Status { get; } + + public bool Deleted { get; } + + public bool Disabled { get; } + + public override string ToString() + { + var serializer = new SerializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .DisableAliases() + .Build(); + return serializer.Serialize(this); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Model/MachinesItemServerStatsOutput.cs b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Model/MachinesItemServerStatsOutput.cs new file mode 100644 index 0000000..0afb52b --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Model/MachinesItemServerStatsOutput.cs @@ -0,0 +1,36 @@ +using Unity.Services.Gateway.GameServerHostingApiV1.Generated.Model; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace Unity.Services.Cli.GameServerHosting.Model; + +public class MachinesItemServerStatsOutput +{ + public MachinesItemServerStatsOutput(ServersStates serversStates) + { + Allocated = serversStates.Allocated; + Available = serversStates.Available; + Held = serversStates.Held; + Online = serversStates.Online; + Reserved = serversStates.Reserved; + } + + public long Allocated { get; } + + public long Available { get; } + + public long Held { get; } + + public long Online { get; } + + public long Reserved { get; } + + public override string ToString() + { + var serializer = new SerializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .DisableAliases() + .Build(); + return serializer.Serialize(this); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Model/MachinesItemSpecOutput.cs b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Model/MachinesItemSpecOutput.cs new file mode 100644 index 0000000..7386b8c --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Model/MachinesItemSpecOutput.cs @@ -0,0 +1,36 @@ +using Unity.Services.Gateway.GameServerHostingApiV1.Generated.Model; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace Unity.Services.Cli.GameServerHosting.Model; + +public class MachinesItemSpecOutput +{ + public MachinesItemSpecOutput(MachineSpec machineSpec) + { + CpuCores = machineSpec.CpuCores; + CpuShortname = machineSpec.CpuShortname; + CpuSpeed = machineSpec.CpuSpeed; + CpuType = machineSpec.CpuType; + Memory = machineSpec.Memory; + } + + public long CpuCores { get; } + + public string CpuShortname { get; } + + public long CpuSpeed { get; } + + public string CpuType { get; } + + public long Memory { get; } + + public override string ToString() + { + var serializer = new SerializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .DisableAliases() + .Build(); + return serializer.Serialize(this); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Model/MachinesOutput.cs b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Model/MachinesOutput.cs new file mode 100644 index 0000000..1aa1548 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Model/MachinesOutput.cs @@ -0,0 +1,22 @@ +using Machine = Unity.Services.Gateway.GameServerHostingApiV1.Generated.Model.Machine1; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace Unity.Services.Cli.GameServerHosting.Model; + +public class MachinesOutput : List +{ + public MachinesOutput(IReadOnlyCollection? machines) + { + if (machines != null) AddRange(machines.Select(m => new MachinesItemOutput(m))); + } + + public override string ToString() + { + var serializer = new SerializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .DisableAliases() + .Build(); + return serializer.Serialize(this); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Service/GameServerHostingService.cs b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Service/GameServerHostingService.cs index caedea6..4aa9469 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Service/GameServerHostingService.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Service/GameServerHostingService.cs @@ -11,14 +11,18 @@ public GameServerHostingService( IServiceAccountAuthenticationService authenticationService, IBuildsApi buildsApi, IBuildConfigurationsApi buildConfigurationsApi, + IFilesApi filesApi, IFleetsApi fleetsApi, + IMachinesApi machinesApi, IServersApi serversApi ) { m_AuthenticationService = authenticationService; BuildsApi = buildsApi; BuildConfigurationsApi = buildConfigurationsApi; + FilesApi = filesApi; FleetsApi = fleetsApi; + MachinesApi = machinesApi; ServersApi = serversApi; } @@ -26,8 +30,12 @@ IServersApi serversApi public IBuildConfigurationsApi BuildConfigurationsApi { get; } + public IFilesApi FilesApi { get; } + public IFleetsApi FleetsApi { get; } + public IMachinesApi MachinesApi { get; } + public IServersApi ServersApi { get; } @@ -36,7 +44,9 @@ public async Task AuthorizeGameServerHostingService(CancellationToken cancellati var token = await m_AuthenticationService.GetAccessTokenAsync(cancellationToken); BuildsApi.Configuration.DefaultHeaders.Add("Authorization", $"Basic {token}"); BuildConfigurationsApi.Configuration.DefaultHeaders.Add("Authorization", $"Basic {token}"); + FilesApi.Configuration.DefaultHeaders.Add("Authorization", $"Basic {token}"); FleetsApi.Configuration.DefaultHeaders.Add("Authorization", $"Basic {token}"); + MachinesApi.Configuration.DefaultHeaders.Add("Authorization", $"Basic {token}"); ServersApi.Configuration.DefaultHeaders.Add("Authorization", $"Basic {token}"); } } diff --git a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Service/IGameServerHostingService.cs b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Service/IGameServerHostingService.cs index 26d4cc2..2e57f9e 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Service/IGameServerHostingService.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Service/IGameServerHostingService.cs @@ -6,7 +6,9 @@ public interface IGameServerHostingService { public IBuildsApi BuildsApi { get; } public IBuildConfigurationsApi BuildConfigurationsApi { get; } + public IFilesApi FilesApi { get; } public IFleetsApi FleetsApi { get; } + public IMachinesApi MachinesApi { get; } public IServersApi ServersApi { get; } public Task AuthorizeGameServerHostingService(CancellationToken cancellationToken = default); diff --git a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Unity.Services.Cli.GameServerHosting.csproj b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Unity.Services.Cli.GameServerHosting.csproj index 7fa5c33..edc7b53 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Unity.Services.Cli.GameServerHosting.csproj +++ b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Unity.Services.Cli.GameServerHosting.csproj @@ -24,7 +24,7 @@ - + diff --git a/Unity.Services.Cli/Unity.Services.Cli.Integration.MockServer/ServiceMocks/AccessApiMock.cs b/Unity.Services.Cli/Unity.Services.Cli.Integration.MockServer/ServiceMocks/AccessApiMock.cs index 1f591bb..25141c8 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.Integration.MockServer/ServiceMocks/AccessApiMock.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.Integration.MockServer/ServiceMocks/AccessApiMock.cs @@ -35,7 +35,16 @@ public AccessApiMock() static Policy GetPolicy() { - List statementLists = new List(); + var statement = new Statement( + "statement-1", + new List() + { + "*" + }, + "Deny", + "Player", + "urn:ugs:*"); + List statementLists = new List(){statement}; var policy = new Policy(statementLists); return policy; diff --git a/Unity.Services.Cli/Unity.Services.Cli.Integration.MockServer/ServiceMocks/GameServerHosting/GameServerHostingApiMock.cs b/Unity.Services.Cli/Unity.Services.Cli.Integration.MockServer/ServiceMocks/GameServerHosting/GameServerHostingApiMock.cs index 749ba0e..caab193 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.Integration.MockServer/ServiceMocks/GameServerHosting/GameServerHostingApiMock.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.Integration.MockServer/ServiceMocks/GameServerHosting/GameServerHostingApiMock.cs @@ -1,6 +1,8 @@ using System.Net; using Unity.Services.Cli.MockServer.Common; using Unity.Services.Gateway.GameServerHostingApiV1.Generated.Model; +using File = Unity.Services.Gateway.GameServerHostingApiV1.Generated.Model.File; +using Machine = Unity.Services.Gateway.GameServerHostingApiV1.Generated.Model.Machine1; using WireMock.Admin.Mappings; using WireMock.Net.OpenApiParser.Settings; using WireMock.RequestBuilders; @@ -13,8 +15,8 @@ public class GameServerHostingApiMock : IServiceApiMock { public async Task> CreateMappingModels() { - var models = await MappingModelUtils.ParseMappingModelsFromGeneratorConfigAsync( - "game-server-hosting-api-v1-generator-config.yaml", + var models = await MappingModelUtils.ParseMappingModelsAsync( + "OpenApi/Specs/game-server-hosting/ignore-hidden-usg-v1-dereferenced.yaml", new WireMockOpenApiParserSettings() ); @@ -36,12 +38,14 @@ public void CustomMock(WireMockServer mockServer) MockBuild(mockServer); MockBuildList(mockServer); MockBuildCreateResponse(mockServer); + MockFiles(mockServer); MockFleetRegionCreateResponse(mockServer); MockFleetAvailableRegionsResponse(mockServer); MockBuildConfigurationCreate(mockServer); MockBuildConfigurationGet(mockServer); MockBuildConfigurationUpdate(mockServer); MockBuildConfigurationList(mockServer); + MockMachineList(mockServer); } static void MockFleetGet(WireMockServer mockServer) @@ -92,7 +96,12 @@ static void MockServerGet(WireMockServer mockServer) locationName: "Test Location", machineID: 123, machineName: "test machine", - machineSpec: new MachineSpec1("2020-12-31T12:00:00Z", "2020-01-01T12:00:00Z", "test-cpu"), + machineSpec: new MachineSpec1( + contractEndDate: new DateTime(2020, 12, 31, 12, 0, 0, DateTimeKind.Utc), + contractStartDate: new DateTime(2020, 1, 1, 12, 0, 0, DateTimeKind.Utc), + cpuName: "test-cpu", + cpuShortname: "tc" + ), port: 0, status: Server.StatusEnum.ONLINE ); @@ -124,7 +133,12 @@ static void MockServerList(WireMockServer mockServer) locationName: "Test Location", machineID: 123, machineName: "test machine", - machineSpec: new MachineSpec1("2020-12-31T12:00:00Z", "2020-01-01T12:00:00Z", "test-cpu"), + machineSpec: new MachineSpec1( + contractEndDate: new DateTime(2020, 12, 31, 12, 0,0, DateTimeKind.Utc), + contractStartDate: new DateTime(2020, 1, 1, 12, 0, 0, DateTimeKind.Utc), + cpuName: "test-cpu", + cpuShortname: "tc" + ), port: 0, status: Server.StatusEnum.ONLINE ) @@ -140,9 +154,42 @@ static void MockServerList(WireMockServer mockServer) mockServer.Given(request).RespondWith(response); } + + static void MockFiles(WireMockServer mockServer) + { + var files = new List + { + new( + filename: "server.log", + path: "logs/", + fileSize: 100, + createdAt: new DateTime(2022, 10, 11), + lastModified: new DateTime(2022, 10, 12), + fleet: new FleetDetails( + id: new Guid(Keys.ValidFleetId), + name: "Test Fleet" + ), + machine: new Gateway.GameServerHostingApiV1.Generated.Model.Machine( + id: Keys.ValidMachineId, + location: "europe-west1" + ), + serverID: 123 + ) + }; + + var request = Request.Create() + .WithPath(Keys.FilesPath) + .UsingGet(); + + var response = Response.Create() + .WithBodyAsJson(files) + .WithStatusCode(HttpStatusCode.OK); + + mockServer.Given(request).RespondWith(response); + } + static void MockBuildList(WireMockServer mockServer) { - Console.WriteLine(Keys.ValidBuildPath); var build = new List { new( @@ -354,8 +401,7 @@ static void MockBuildCreateResponse(WireMockServer mockServer) static void MockBuildConfigurationCreate(WireMockServer mockServer) { - var buildConfig = new BuildConfiguration - ( + var buildConfig = new BuildConfiguration( binaryPath: "simple-game-server-go", buildID: Keys.ValidBuildConfigurationId, buildName: "Build 1", @@ -387,8 +433,7 @@ static void MockBuildConfigurationCreate(WireMockServer mockServer) static void MockBuildConfigurationGet(WireMockServer mockServer) { - var buildConfig = new BuildConfiguration - ( + var buildConfig = new BuildConfiguration( binaryPath: "simple-game-server-go", buildID: Keys.ValidBuildConfigurationId, buildName: "Build 1", @@ -420,8 +465,7 @@ static void MockBuildConfigurationGet(WireMockServer mockServer) static void MockBuildConfigurationUpdate(WireMockServer mockServer) { - var buildConfig = new BuildConfiguration - ( + var buildConfig = new BuildConfiguration( binaryPath: "simple-game-server-go", buildID: Keys.ValidBuildConfigurationId, buildName: "Build 1", @@ -465,7 +509,7 @@ static void MockBuildConfigurationList(WireMockServer mockServer) "config name", new DateTime(2022, 10, 11), 1 - ), + ), new( 2, "build config 2", @@ -533,4 +577,49 @@ static void MockFleetAvailableRegionsResponse(WireMockServer mockServer) } public const string TempFileName = "temp-file.txt"; + + static void MockMachineList(WireMockServer mockServer) + { + var machines = new List + { + new( + id: Keys.ValidMachineId, + ip: "127.0.0.10", + name: "p-gce-test-2", + locationId: 111111, + locationName: "us-west1", + fleetId: new Guid(Keys.ValidFleetId), + fleetName: "Fleet One", + hardwareType: Machine.HardwareTypeEnum.CLOUD, + osFamily: Machine.OsFamilyEnum.LINUX, + osName: "Ubuntu (Server) 22.04 LTS", + serversStates: new ServersStates( + 0, + 5, + 2, + 1 + ), + spec: new MachineSpec( + 8, + "U1.Standard.3", + 2800, + "Cloud Intel 2nd Gen Scalable", + 32212254720 + ), + status: Machine.StatusEnum.ONLINE, + deleted: false, + disabled: false + ) + }; + + var request = Request.Create() + .WithPath(Keys.MachinesPath) + .UsingGet(); + + var response = Response.Create() + .WithBodyAsJson(machines) + .WithStatusCode(HttpStatusCode.OK); + + mockServer.Given(request).RespondWith(response); + } } diff --git a/Unity.Services.Cli/Unity.Services.Cli.Integration.MockServer/ServiceMocks/GameServerHosting/Keys.cs b/Unity.Services.Cli/Unity.Services.Cli.Integration.MockServer/ServiceMocks/GameServerHosting/Keys.cs index 9863497..23120bd 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.Integration.MockServer/ServiceMocks/GameServerHosting/Keys.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.Integration.MockServer/ServiceMocks/GameServerHosting/Keys.cs @@ -18,14 +18,16 @@ public static class Keys public const long ValidBuildIdBucket = 101; public const long ValidBuildIdContainer = 102; public const long ValidBuildIdFileUpload = 103; - + public const long ValidMachineId = 654321L; public const string ValidServerId = "123"; public const string ProjectPathPart = $"projects/{CommonKeys.ValidProjectId}/environments/{CommonKeys.ValidEnvironmentId}"; + public const string FilesPath = $"/multiplay/files/v1/{ProjectPathPart}/files"; public const string FleetsPath = $"/multiplay/fleets/v1/{ProjectPathPart}/fleets"; public const string ServersPath = $"/multiplay/servers/v1/{ProjectPathPart}/servers"; + public const string MachinesPath = $"/multiplay/machines/v1/{ProjectPathPart}/machines"; public const string ValidFleetPath = $"{FleetsPath}/{ValidFleetId}"; public const string ValidServersPath = $"{ServersPath}/{ValidServerId}"; diff --git a/Unity.Services.Cli/Unity.Services.Cli.Integration.MockServer/ServiceMocks/TriggersApiMock.cs b/Unity.Services.Cli/Unity.Services.Cli.Integration.MockServer/ServiceMocks/TriggersApiMock.cs new file mode 100644 index 0000000..4258adb --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Integration.MockServer/ServiceMocks/TriggersApiMock.cs @@ -0,0 +1,92 @@ +using System.Net; +using Unity.Services.Cli.MockServer.Common; +using Unity.Services.Gateway.TriggersApiV1.Generated.Model; +using WireMock.Admin.Mappings; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; + +namespace Unity.Services.Cli.MockServer.ServiceMocks; + +public class TriggersApiMock : IServiceApiMock +{ + const string k_TriggersPath = "/triggers/v0"; + const string k_BaseUrl = $"{k_TriggersPath}/projects/{CommonKeys.ValidProjectId}/environments/{CommonKeys.ValidEnvironmentId}/configs"; + + public static readonly TriggerConfig Trigger1 = new( + Guid.Parse("00000000-0000-0000-0000-000000000001"), DateTime.Now, DateTime.Now, "Trigger1", + Guid.Parse(CommonKeys.ValidProjectId), Guid.Parse(CommonKeys.ValidEnvironmentId), "eventType", + TriggerActionType.CloudCode, "cloudcode:blah" + ); + public static readonly TriggerConfig Trigger2 = new( + Guid.Parse("00000000-0000-0000-0000-000000000002"), DateTime.Now, DateTime.Now, "Trigger2", + Guid.Parse(CommonKeys.ValidProjectId), Guid.Parse(CommonKeys.ValidEnvironmentId), "eventType", + TriggerActionType.CloudCode, "cloudcode:blah" + ); + + + readonly Dictionary m_RequestHeader = new () + { + { "Content-Type", "application/json" } + }; + + public Task> CreateMappingModels() + { + IReadOnlyList models = new List(); + return Task.FromResult(models); + } + + public void CustomMock(WireMockServer mockServer) + { + MockListTriggers(mockServer, new List() + { + Trigger1, + Trigger2 + }); + + MockGetTrigger(mockServer, Trigger1); + MockDeleteTrigger(mockServer, Trigger1.Id.ToString()); + MockCreateTrigger(mockServer, Trigger1); + } + + void MockListTriggers(WireMockServer mockServer, List triggerConfigs, + HttpStatusCode code = HttpStatusCode.OK) + { + var response = new TriggerConfigPage() + { + Configs = triggerConfigs + }; + + mockServer.Given(Request.Create().WithPath(k_BaseUrl).UsingGet()) + .RespondWith(Response.Create() + .WithHeaders(m_RequestHeader) + .WithBodyAsJson(response) + .WithStatusCode(code)); + } + + void MockGetTrigger(WireMockServer mockServer, TriggerConfig triggerConfig, HttpStatusCode code = HttpStatusCode.OK) + { + mockServer.Given(Request.Create().WithPath(k_BaseUrl + "/" + triggerConfig.Id).UsingGet()) + .RespondWith(Response.Create() + .WithHeaders(m_RequestHeader) + .WithBodyAsJson(triggerConfig) + .WithStatusCode(code)); + } + + void MockCreateTrigger(WireMockServer mockServer, TriggerConfig triggerConfig, HttpStatusCode code = HttpStatusCode.Created) + { + mockServer.Given(Request.Create().WithPath(k_BaseUrl).UsingPost()) + .RespondWith(Response.Create() + .WithHeaders(m_RequestHeader) + .WithBodyAsJson(triggerConfig) + .WithStatusCode(code)); + } + + void MockDeleteTrigger(WireMockServer mockServer, string triggerId, HttpStatusCode code = HttpStatusCode.NoContent) + { + mockServer.Given(Request.Create().WithPath(k_BaseUrl + "/" + triggerId).UsingDelete()) + .RespondWith(Response.Create() + .WithHeaders(m_RequestHeader) + .WithStatusCode(code)); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Integration.MockServer/Unity.Services.Cli.Integration.MockServer.csproj b/Unity.Services.Cli/Unity.Services.Cli.Integration.MockServer/Unity.Services.Cli.Integration.MockServer.csproj index 23431a4..fe53310 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.Integration.MockServer/Unity.Services.Cli.Integration.MockServer.csproj +++ b/Unity.Services.Cli/Unity.Services.Cli.Integration.MockServer/Unity.Services.Cli.Integration.MockServer.csproj @@ -9,6 +9,7 @@ true + @@ -24,5 +25,6 @@ + \ No newline at end of file diff --git a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/AccessTests/AccessTests.cs b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/AccessTests/AccessTests.cs index 80720f8..9d2c3ff 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/AccessTests/AccessTests.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/AccessTests/AccessTests.cs @@ -25,7 +25,7 @@ public class AccessTests : UgsCliFixture const string k_EnvironmentNameNotSetErrorMessage = "'environment-name' is not set in project configuration." + " '" + Keys.EnvironmentKeys.EnvironmentName + "' is not set in system environment variables."; - readonly string k_TestDirectory = Path.GetFullPath(Path.Combine(UgsCliBuilder.RootDirectory, "Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/AccessTests/Data/")); + readonly string m_TestDirectory = Path.GetFullPath(Path.Combine(UgsCliBuilder.RootDirectory, "Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/AccessTests/Data/")); const string k_RequiredArgumentMissing = "Required argument missing for command"; @@ -34,17 +34,17 @@ public async Task SetUp() { DeleteLocalConfig(); DeleteLocalCredentials(); - m_MockApi.Server?.ResetMappings(); + MockApi.Server?.ResetMappings(); - await m_MockApi.MockServiceAsync(new IdentityV1Mock()); - await m_MockApi.MockServiceAsync(new AccessApiMock()); + await MockApi.MockServiceAsync(new IdentityV1Mock()); + await MockApi.MockServiceAsync(new AccessApiMock()); } [TearDown] public void TearDown() { - m_MockApi.Server?.ResetMappings(); + MockApi.Server?.ResetMappings(); } @@ -98,7 +98,7 @@ public async Task AccessGetProjectPolicyReturnsZeroExitCode() { SetConfigValue("project-id", CommonKeys.ValidProjectId); SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); - await AssertSuccess("access get-project-policy", expectedStdOut: "\"statements\": []"); + await AssertSuccess("access get-project-policy", expectedStdOut: "statement-1"); } // access get-player-policy @@ -142,7 +142,7 @@ public async Task AccessUpsertProjectPolicyReturnsZeroExitCode() { SetConfigValue("project-id", CommonKeys.ValidProjectId); SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); - await AssertSuccess($"access upsert-project-policy {Path.Combine(k_TestDirectory, "policy.json")}", $"Policy for project: '{CommonKeys.ValidProjectId}' and environment: '{CommonKeys.ValidEnvironmentId}' has been updated"); + await AssertSuccess($"access upsert-project-policy {Path.Combine(m_TestDirectory, "policy.json")}", $"Policy for project: '{CommonKeys.ValidProjectId}' and environment: '{CommonKeys.ValidEnvironmentId}' has been updated"); } // access upsert-player-policy @@ -151,7 +151,7 @@ public async Task AccessUpsertPlayerPolicyReturnsZeroExitCode() { SetConfigValue("project-id", CommonKeys.ValidProjectId); SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); - await AssertSuccess($"access upsert-player-policy {AccessApiMock.PlayerId} {Path.Combine(k_TestDirectory, "policy.json")}", $"Policy for player: '{AccessApiMock.PlayerId}' has been updated"); + await AssertSuccess($"access upsert-player-policy {AccessApiMock.PlayerId} {Path.Combine(m_TestDirectory, "policy.json")}", $"Policy for player: '{AccessApiMock.PlayerId}' has been updated"); } // access delete-project-policy-statements @@ -160,7 +160,7 @@ public async Task AccessDeleteProjectPolicyStatementsReturnsZeroExitCode() { SetConfigValue("project-id", CommonKeys.ValidProjectId); SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); - await AssertSuccess($"access delete-project-policy-statements {Path.Combine(k_TestDirectory, "statements.json")}", $"Given policy statements for project: '{CommonKeys.ValidProjectId}' and environment: '{CommonKeys.ValidEnvironmentId}' has been deleted"); + await AssertSuccess($"access delete-project-policy-statements {Path.Combine(m_TestDirectory, "statements.json")}", $"Given policy statements for project: '{CommonKeys.ValidProjectId}' and environment: '{CommonKeys.ValidEnvironmentId}' has been deleted"); } // access delete-player-policy-statements @@ -169,7 +169,7 @@ public async Task AccessDeletePlayerPolicyStatementsReturnsZeroExitCode() { SetConfigValue("project-id", CommonKeys.ValidProjectId); SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); - await AssertSuccess($"access delete-player-policy-statements {AccessApiMock.PlayerId} {Path.Combine(k_TestDirectory, "statements.json")}", $"Given policy statements for player: '{AccessApiMock.PlayerId}' has been deleted"); + await AssertSuccess($"access delete-player-policy-statements {AccessApiMock.PlayerId} {Path.Combine(m_TestDirectory, "statements.json")}", $"Given policy statements for player: '{AccessApiMock.PlayerId}' has been deleted"); } // helpers @@ -186,7 +186,7 @@ public static IEnumerable AccessModuleCommands yield return $"access delete-player-policy-statements {AccessApiMock.PlayerId} policy.json"; } } - + static async Task AssertSuccess(string command, string? expectedStdErr = null, string? expectedStdOut = null) { var test = GetLoggedInCli() diff --git a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/Authoring/Deploy/CloudCode/CloudCodeDeployTests.cs b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/Authoring/Deploy/CloudCode/CloudCodeDeployTests.cs index 3cce7c6..394c81d 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/Authoring/Deploy/CloudCode/CloudCodeDeployTests.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/Authoring/Deploy/CloudCode/CloudCodeDeployTests.cs @@ -55,9 +55,9 @@ public class CloudCodeDeployTests : UgsCliFixture k_TestDirectory), }; - List m_DeployedContents = new(); - List m_FailedContents = new(); - List m_DryRunContents = new(); + readonly List m_DeployedContents = new(); + readonly List m_FailedContents = new(); + readonly List m_DryRunContents = new(); [SetUp] public async Task SetUp() @@ -72,12 +72,11 @@ public async Task SetUp() m_DeployedContents.Clear(); m_FailedContents.Clear(); - m_MockApi.Server?.ResetMappings(); + MockApi.Server?.ResetMappings(); Directory.CreateDirectory(k_TestDirectory); - await m_MockApi.MockServiceAsync(new IdentityV1Mock()); - await m_MockApi.MockServiceAsync(new CloudCodeV1Mock()); - await m_MockApi.MockServiceAsync(new LeaderboardApiMock()); + await MockApi.MockServiceAsync(new IdentityV1Mock()); + await MockApi.MockServiceAsync(new CloudCodeV1Mock()); } [TearDown] @@ -100,7 +99,7 @@ public async Task DeployValidConfigFromDirectorySucceed() await CreateDeployTestFilesAsync(m_DeployedTestCases, m_DeployedContents); var deployedConfigFileString = string.Join(Environment.NewLine + " ", m_DeployedTestCases.Select(a => $"'{a.ConfigFilePath}'")); await GetLoggedInCli() - .Command($"deploy {k_TestDirectory}") + .Command($"deploy {k_TestDirectory} -s cloud-code-scripts -s cloud-code-modules") .AssertStandardOutputContains($"Successfully deployed the following files:{Environment.NewLine} {deployedConfigFileString}") .AssertNoErrors() .ExecuteAsync(); @@ -120,7 +119,7 @@ public async Task DeployValidConfigFromDirectorySucceedWithJsonOutput() Array.Empty()); var resultString = JsonConvert.SerializeObject(logResult.ToTable(), Formatting.Indented); await GetLoggedInCli() - .Command($"deploy {k_TestDirectory} -j") + .Command($"deploy {k_TestDirectory} -j -s cloud-code-scripts -s cloud-code-modules") .AssertStandardOutputContains(resultString) .AssertNoErrors() .ExecuteAsync(); @@ -142,7 +141,7 @@ public async Task DeployConfig_DryRun() true); var resultString = JsonConvert.SerializeObject(logResult.ToTable(), Formatting.Indented); await GetLoggedInCli() - .Command($"deploy {k_TestDirectory} -j --dry-run") + .Command($"deploy {k_TestDirectory} -j --dry-run -s cloud-code-scripts -s cloud-code-modules") .AssertStandardOutputContains(resultString) .AssertNoErrors() .ExecuteAsync(); @@ -232,7 +231,7 @@ public async Task DeployWithoutReconcileWillNotDeleteRemoteFiles() Array.Empty()); var resultString = JsonConvert.SerializeObject(logResult.ToTable(), Formatting.Indented); await GetLoggedInCli() - .Command($"deploy {k_TestDirectory} -j") + .Command($"deploy {k_TestDirectory} -j -s cloud-code-scripts -s cloud-code-modules") .AssertStandardOutputContains(resultString) .AssertNoErrors() .ExecuteAsync(); diff --git a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/Authoring/Deploy/DeployPreconditionTests.cs b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/Authoring/Deploy/DeployPreconditionTests.cs index 3f483ed..0282810 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/Authoring/Deploy/DeployPreconditionTests.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/Authoring/Deploy/DeployPreconditionTests.cs @@ -18,8 +18,8 @@ public async Task SetUp() { DeleteLocalConfig(); DeleteLocalCredentials(); - m_MockApi.Server?.ResetMappings(); - await m_MockApi.MockServiceAsync(new IdentityV1Mock()); + MockApi.Server?.ResetMappings(); + await MockApi.MockServiceAsync(new IdentityV1Mock()); } [Test] diff --git a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/Authoring/Deploy/Economy/EconomyDeployTests.cs b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/Authoring/Deploy/Economy/EconomyDeployTests.cs new file mode 100644 index 0000000..48ff7ea --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/Authoring/Deploy/Economy/EconomyDeployTests.cs @@ -0,0 +1,135 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Newtonsoft.Json; +using NUnit.Framework; +using Unity.Services.Cli.Authoring.Model; +using Unity.Services.Cli.MockServer.ServiceMocks; +using Unity.Services.DeploymentApi.Editor; +using Unity.Services.Gateway.EconomyApiV2.Generated.Model; + +namespace Unity.Services.Cli.IntegrationTest.Authoring.Deploy.Economy; + +public class EconomyDeployTests : DeployTestsFixture +{ + static CurrencyItemResponse s_Currency = new( + "SILVER_TOKEN", + "Silver Token", + CurrencyItemResponse.TypeEnum.CURRENCY, + 0, + 100, + "custom data", + new ModifiedMetadata(new DateTime(2023, 1, 1)), + new ModifiedMetadata(new DateTime(2023, 1, 1)) + ); + static string s_ResourceFileText = s_Currency.ToJson(); + + protected override AuthoringTestCase GetValidTestCase() + { + return new AuthoringTestCase( + s_ResourceFileText, + s_Currency.Name, + $"{EconomyApiMock.ValidFileName}.ecc", + "Currency", + 100, + Statuses.Deployed, + "", + TestDirectory); + } + + protected override AuthoringTestCase GetInvalidTestCase() + { + return new AuthoringTestCase( + "bad file content", + s_Currency.Name, + $"{EconomyApiMock.ValidFileName}.ecc", + "Currency", + 100, + Statuses.FailedToRead, + "", + TestDirectory); + } + + [SetUp] + public new async Task SetUp() + { + await MockApi.MockServiceAsync(new EconomyApiMock()); + await MockApi.MockServiceAsync(new LeaderboardApiMock()); + await MockApi.MockServiceAsync(new TriggersApiMock()); + } + + [Test] + public async Task DeployWithReconcileWillDeleteRemoteFiles() + { + var content = + new DeployContent( + EconomyApiMock.Currency.Name, + "Currency", + "Remote", + 100.0f, + Statuses.Deployed, + "Deleted remotely"); + var createdContentList = await CreateDeployTestFilesAsync(DeployedTestCases); + + //TODO: remove this after message details will be adjusted in other services or removed from Economy module + createdContentList[0].Status = new DeploymentStatus(Statuses.Deployed, "Created remotely"); + // deployed content list has the same as the created + the content economy content deployed + var deployedContentList = createdContentList.ToList(); + deployedContentList.Add(content); + + var logResult = CreateResult( + createdContentList, + Array.Empty(), + new[] + { + content + }, + deployedContentList, + Array.Empty()); + + var resultString = JsonConvert.SerializeObject(logResult.ToTable(), Formatting.Indented); + + await GetFullySetCli() + .Command($"deploy {TestDirectory} -j --reconcile -s economy") + .AssertStandardOutputContains(resultString) + .AssertNoErrors() + .ExecuteAsync(); + } + + [Test] + public async Task DeployEmptyFolderWithReconcileFails() + { + var logResult = CreateResult( + Array.Empty(), + Array.Empty(), + Array.Empty(), + Array.Empty(), + Array.Empty()); + + var messages = new List(); + messages.Add( + new JsonLogMessage() + { + Message = + "Economy service deployment cannot be used in an empty folder while using reconcile option. " + + "You cannot have an empty published configuration.", + Type = "Warning" + }); + + var resultString = JsonConvert.SerializeObject(logResult.ToTable(), Formatting.Indented); + var messageString = JsonConvert.SerializeObject(messages, Formatting.Indented); + + await GetFullySetCli() + .Command($"deploy {TestDirectory} -j --reconcile -s economy") + .AssertStandardOutputContains(resultString) + .AssertStandardErrorContains(messageString) + .ExecuteAsync(); + } + + class JsonLogMessage + { + public string? Message { get; set; } + public string? Type { get; set; } + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/Authoring/Deploy/Leaderboards/LeaderboardDeployTests.cs b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/Authoring/Deploy/Leaderboards/LeaderboardDeployTests.cs index 3d41844..f1cb213 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/Authoring/Deploy/Leaderboards/LeaderboardDeployTests.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/Authoring/Deploy/Leaderboards/LeaderboardDeployTests.cs @@ -17,7 +17,6 @@ namespace Unity.Services.Cli.IntegrationTest.Authoring.Deploy.Leaderboards; */ public class LeaderboardDeployTests : UgsCliFixture { - static readonly string k_TestDirectory = Path.Combine(UgsCliBuilder.RootDirectory, ".tmp", "FilesDir"); readonly IReadOnlyList m_DeployedTestCases = new[] @@ -32,8 +31,8 @@ public class LeaderboardDeployTests : UgsCliFixture k_TestDirectory), }; - List m_DeployedContents = new(); - List m_FailedContents = new(); + readonly List m_DeployedContents = new(); + readonly List m_FailedContents = new(); [SetUp] public async Task SetUp() @@ -48,9 +47,9 @@ public async Task SetUp() m_DeployedContents.Clear(); m_FailedContents.Clear(); - m_MockApi.Server?.ResetMappings(); - await m_MockApi.MockServiceAsync(new LeaderboardApiMock()); - await m_MockApi.MockServiceAsync(new IdentityV1Mock()); + MockApi.Server?.ResetMappings(); + await MockApi.MockServiceAsync(new LeaderboardApiMock()); + await MockApi.MockServiceAsync(new IdentityV1Mock()); Directory.CreateDirectory(k_TestDirectory); } @@ -81,7 +80,7 @@ public async Task DeployValidConfigFromDirectoryWithOptionSucceed() await CreateDeployTestFilesAsync(m_DeployedTestCases, m_DeployedContents); var deployedConfigFileString = string.Join(System.Environment.NewLine + " ", m_DeployedTestCases.Select(r => $"'{r.ConfigFilePath}'")); await GetLoggedInCli() - .Command($"deploy {k_TestDirectory} -p {CommonKeys.ValidProjectId} -e {CommonKeys.ValidEnvironmentName}") + .Command($"deploy {k_TestDirectory} -p {CommonKeys.ValidProjectId} -e {CommonKeys.ValidEnvironmentName} -s leaderboards") .AssertStandardOutputContains($"Successfully deployed the following files:{System.Environment.NewLine} {deployedConfigFileString}") .AssertNoErrors() .ExecuteAsync(); @@ -95,7 +94,7 @@ public async Task DeployValidConfigFromDirectorySucceed() await CreateDeployTestFilesAsync(m_DeployedTestCases, m_DeployedContents); var deployedConfigFileString = string.Join(System.Environment.NewLine + " ", m_DeployedTestCases.Select(r => $"'{r.ConfigFilePath}'")); await GetLoggedInCli() - .Command($"deploy {k_TestDirectory}") + .Command($"deploy {k_TestDirectory} -s leaderboards") .AssertStandardOutputContains($"Successfully deployed the following files:{System.Environment.NewLine} {deployedConfigFileString}") .AssertNoErrors() .ExecuteAsync(); diff --git a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/Authoring/Deploy/ProjectAccess/ProjectAccessDeployTests.cs b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/Authoring/Deploy/ProjectAccess/ProjectAccessDeployTests.cs new file mode 100644 index 0000000..5a8e15c --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/Authoring/Deploy/ProjectAccess/ProjectAccessDeployTests.cs @@ -0,0 +1,136 @@ +using System.IO; +using System.Linq; +using NUnit.Framework; +using System.Threading.Tasks; +using System.Collections.Generic; +using Unity.Services.Access.Authoring.Core.Model; +using Unity.Services.Cli.Authoring.Model; +using Unity.Services.DeploymentApi.Editor; +using Unity.Services.Cli.Common.Exceptions; +using Unity.Services.Cli.MockServer.Common; +using Unity.Services.Cli.MockServer.ServiceMocks; +using Unity.Services.Cli.MockServer.ServiceMocks.RemoteConfig; + +namespace Unity.Services.Cli.IntegrationTest.Authoring.Deploy.ProjectAccess; + +[Ignore("It's breaking the CI")] +public class ProjectAccessDeployTests : UgsCliFixture +{ + + static readonly string k_TestDirectory = Path.Combine(UgsCliBuilder.RootDirectory, ".tmp"); + + readonly IReadOnlyList m_DeployedTestCases = new[] + { + new AuthoringTestCase( + "{ \"Statements\": [ {\"Sid\": \"allow-access-to-economy\",\"Action\": [ \"Read\"],\"Effect\": \"Allow\",\"Principal\": \"Player\",\"Resource\": \"urn:ugs:economy:*\",\"ExpiresAt\": \"2024-04-29T18:30:51.243Z\",\"Version\": \"10.0\" } ]}", + "statements.ac", + "Access File", + 100, + "Deployed", + "Deployed Successfully", + k_TestDirectory, + SeverityLevel.Success), + }; + + readonly List m_DeployedContents = new(); + readonly List m_FailedContents = new(); + + [SetUp] + public async Task SetUp() + { + DeleteLocalConfig(); + DeleteLocalCredentials(); + + if (Directory.Exists(k_TestDirectory)) + { + Directory.Delete(k_TestDirectory, true); + } + + m_DeployedContents.Clear(); + m_FailedContents.Clear(); + MockApi.Server?.ResetMappings(); + + Directory.CreateDirectory(k_TestDirectory); + + await MockApi.MockServiceAsync(new AccessApiMock()); + await MockApi.MockServiceAsync(new RemoteConfigMock()); + await MockApi.MockServiceAsync(new LeaderboardApiMock()); + await MockApi.MockServiceAsync(new IdentityV1Mock()); + } + + + [TearDown] + public void TearDown() + { + DeleteLocalConfig(); + DeleteLocalCredentials(); + + if (Directory.Exists(k_TestDirectory)) + { + Directory.Delete(k_TestDirectory, true); + } + } + + static async Task CreateDeployTestFilesAsync(IReadOnlyList testCases, ICollection contents) + { + foreach (var testCase in testCases) + { + await File.WriteAllTextAsync(testCase.ConfigFilePath, testCase.ConfigValue); + contents.Add(testCase.DeployedContent); + } + } + + [Test] + public async Task DeployValidConfigFromDirectoryWithOptionSucceed() + { + await CreateDeployTestFilesAsync(m_DeployedTestCases, m_DeployedContents); + var deployedConfigFileString = string.Join(System.Environment.NewLine + " ", m_DeployedTestCases.Select(r => $"'{r.ConfigFilePath}'")); + + await GetLoggedInCli() + .Command($"deploy {k_TestDirectory} -p {CommonKeys.ValidProjectId} -e {CommonKeys.ValidEnvironmentName}") + .AssertStandardOutputContains($"Successfully deployed the following files:{System.Environment.NewLine} {deployedConfigFileString}") + .AssertNoErrors() + .ExecuteAsync(); + } + + [Test] + public async Task DeployValidConfigFromDirectorySucceed() + { + SetConfigValue("project-id", CommonKeys.ValidProjectId); + SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); + await CreateDeployTestFilesAsync(m_DeployedTestCases, m_DeployedContents); + var deployedConfigFileString = string.Join(System.Environment.NewLine + " ", m_DeployedTestCases.Select(r => $"'{r.ConfigFilePath}'")); + await GetLoggedInCli() + .Command($"deploy {k_TestDirectory}") + .AssertStandardOutputContains($"Successfully deployed the following files:{System.Environment.NewLine} {deployedConfigFileString}") + .AssertNoErrors() + .ExecuteAsync(); + } + + [Test] + public async Task DeployValidConfigDryRunSucceed() + { + await CreateDeployTestFilesAsync(m_DeployedTestCases, m_DeployedContents); + var deployedConfigFileString = string.Join(System.Environment.NewLine + " ", m_DeployedTestCases.Select(r => $"'{r.ConfigFilePath}'")); + + await GetLoggedInCli() + .Command($"deploy {k_TestDirectory} -p {CommonKeys.ValidProjectId} -e {CommonKeys.ValidEnvironmentName} --dry-run") + .AssertStandardOutputContains($"Will deploy following files:{System.Environment.NewLine} {deployedConfigFileString}") + .AssertNoErrors() + .ExecuteAsync(); + } + + + [Test] + public async Task DeployInvalidPath() + { + var invalidDirectory = Path.GetFullPath("invalid-directory"); + var expectedOutput = $"Path \"{invalidDirectory}\" could not be found."; + + await GetLoggedInCli() + .Command($"deploy {invalidDirectory} -p {CommonKeys.ValidProjectId} -e {CommonKeys.ValidEnvironmentName}") + .AssertStandardErrorContains(expectedOutput) + .AssertExitCode(ExitCode.HandledError) + .ExecuteAsync(); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/Authoring/Deploy/RemoteConfig/RemoteConfigDeployTests.cs b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/Authoring/Deploy/RemoteConfig/RemoteConfigDeployTests.cs index 46e56df..8f0118e 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/Authoring/Deploy/RemoteConfig/RemoteConfigDeployTests.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/Authoring/Deploy/RemoteConfig/RemoteConfigDeployTests.cs @@ -98,9 +98,9 @@ public class RemoteConfigDeployTests : UgsCliFixture SeverityLevel.Error) }; - List m_DeployedContents = new(); - List m_FailedContents = new(); - List m_DryRunContents = new(); + readonly List m_DeployedContents = new(); + readonly List m_FailedContents = new(); + readonly List m_DryRunContents = new(); [SetUp] public async Task SetUp() @@ -115,13 +115,12 @@ public async Task SetUp() m_DeployedContents.Clear(); m_FailedContents.Clear(); - m_MockApi.Server?.ResetMappings(); + MockApi.Server?.ResetMappings(); Directory.CreateDirectory(k_TestDirectory); - await m_MockApi.MockServiceAsync(new IdentityV1Mock()); - await m_MockApi.MockServiceAsync(new RemoteConfigMock()); - await m_MockApi.MockServiceAsync(new LeaderboardApiMock()); + await MockApi.MockServiceAsync(new IdentityV1Mock()); + await MockApi.MockServiceAsync(new RemoteConfigMock()); } static async Task CreateDeployTestFilesAsync(IReadOnlyList testCases, ICollection contents) @@ -143,7 +142,10 @@ static async Task CreateDeployTestFilesAsync(IReadOnlyList te continue; } - catch { } + catch + { + // ignored + } } contents.Add(testCase.DeployedContent); @@ -171,7 +173,7 @@ public async Task DeployValidConfigFromDirectorySucceed() var deployedConfigFileString = string.Join(Environment.NewLine + " ", m_DeployedTestCases.Select(GetConfigFileString)); await GetLoggedInCli() - .Command($"deploy {k_TestDirectory}") + .Command($"deploy {k_TestDirectory} -s remote-config") .AssertStandardOutputContains($"Successfully deployed the following files:{Environment.NewLine} {deployedConfigFileString}") .AssertNoErrors() .ExecuteAsync(); @@ -183,7 +185,7 @@ public async Task DeployValidConfigWithOptionsSucceed() await CreateDeployTestFilesAsync(m_DeployedTestCases, m_DeployedContents); var deployedConfigFileString = string.Join(Environment.NewLine + " ", m_DeployedTestCases.Select(GetConfigFileString)); await GetLoggedInCli() - .Command($"deploy {k_TestDirectory} -p {CommonKeys.ValidProjectId} -e {CommonKeys.ValidEnvironmentName}") + .Command($"deploy {k_TestDirectory} -p {CommonKeys.ValidProjectId} -e {CommonKeys.ValidEnvironmentName} -s remote-config") .AssertStandardOutputContains($"Successfully deployed the following files:{Environment.NewLine} {deployedConfigFileString}") .AssertNoErrors() .ExecuteAsync(); @@ -195,7 +197,7 @@ public async Task DeployNoConfigFromDirectorySucceed() SetConfigValue("project-id", CommonKeys.ValidProjectId); SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); await GetLoggedInCli() - .Command($"deploy {k_TestDirectory}") + .Command($"deploy {k_TestDirectory} -s remote-config") .AssertStandardOutputContains($"No content deployed") .AssertNoErrors() .ExecuteAsync(); @@ -210,7 +212,7 @@ public async Task DeployConfigWithInvalidOnlyFailInvalid() await CreateDeployTestFilesAsync(m_FailedTestCases, m_FailedContents); var deployedConfigFileString = string.Join(Environment.NewLine + " ", m_DeployedTestCases.Select(GetConfigFileString)); await GetLoggedInCli() - .Command($"deploy {k_TestDirectory}") + .Command($"deploy {k_TestDirectory} -s remote-config") .AssertStandardOutput(output => { StringAssert.Contains($"Successfully deployed the following files:{Environment.NewLine} {deployedConfigFileString}", output); @@ -247,7 +249,7 @@ public async Task DeployConfigWithInvalidWithJsonOutputOnlyFailInvalid() m_FailedTestCases.Select(d => d.DeployedContent).ToList()); await GetLoggedInCli() - .Command($"deploy {k_TestDirectory} -j") + .Command($"deploy {k_TestDirectory} -j -s remote-config") .AssertStandardOutputContains(JsonConvert.SerializeObject(logResult, Formatting.Indented)) .AssertExitCode(ExitCode.HandledError) .ExecuteAsync(); @@ -289,7 +291,7 @@ public async Task DeployConfig_DryRun() var resultString = JsonConvert.SerializeObject(logResult.ToTable(), Formatting.Indented); await GetLoggedInCli() - .Command($"deploy {k_TestDirectory} -j --dry-run") + .Command($"deploy {k_TestDirectory} -j --dry-run -s remote-config") .AssertStandardOutputContains(resultString) .AssertNoErrors() .ExecuteAsync(); @@ -344,7 +346,7 @@ public async Task DeployWithoutReconcileWillNotDeleteRemoteFiles() var resultString = JsonConvert.SerializeObject(logResult, Formatting.Indented); await GetLoggedInCli() - .Command($"deploy {k_TestDirectory} -j") + .Command($"deploy {k_TestDirectory} -j -s remote-config") .AssertStandardOutputContains(resultString) .AssertNoErrors() .ExecuteAsync(); diff --git a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/Authoring/Deploy/RemoteConfig/RemoteConfigFileContent.cs b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/Authoring/Deploy/RemoteConfig/RemoteConfigFileContent.cs index 2ef7c6e..6a90ed4 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/Authoring/Deploy/RemoteConfig/RemoteConfigFileContent.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/Authoring/Deploy/RemoteConfig/RemoteConfigFileContent.cs @@ -9,7 +9,8 @@ namespace Unity.Services.Cli.IntegrationTest.Authoring.Deploy.RemoteConfig; public class RemoteConfigFileContent { - public Dictionary entries; + // ReSharper disable once InconsistentNaming + public readonly Dictionary entries; public RemoteConfigFileContent() { diff --git a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/Authoring/Deploy/Triggers/TriggerDeployTests.cs b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/Authoring/Deploy/Triggers/TriggerDeployTests.cs new file mode 100644 index 0000000..d995154 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/Authoring/Deploy/Triggers/TriggerDeployTests.cs @@ -0,0 +1,99 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using NUnit.Framework; +using Unity.Services.Cli.Authoring.Model; +using Unity.Services.Cli.Common.Networking; +using Unity.Services.Cli.MockServer; +using Unity.Services.Cli.MockServer.Common; +using Unity.Services.Cli.MockServer.ServiceMocks; + +namespace Unity.Services.Cli.IntegrationTest.Authoring.Deploy.Triggers; + +[Ignore("These tests pass individually but fail when run as part of a batch. Since it doesn't seem to be an issue with prod code, ignoring for now - DM")] +public class TriggersDeployTests : UgsCliFixture +{ + static readonly string k_TestDirectory = Path.Combine(UgsCliBuilder.RootDirectory, ".tmp", "FilesDir"); + + readonly IReadOnlyList m_DeployedTestCases = new[] + { + new AuthoringTestCase( + "{\"Configs\":[{\"Name\":\"Trigger1\",\"EventType\":\"EventType1\",\"ActionType\":\"cloud-code\",\"ActionUrn\":\"ActionUrn1\"}]}", + "Triggers1.tr", + "Trigger", + 100, + "Deployed", + "Deployed Successfully", + k_TestDirectory), + }; + + List m_DeployedContents = new(); + List m_FailedContents = new(); + + [SetUp] + public async Task SetUp() + { + DeleteLocalConfig(); + DeleteLocalCredentials(); + + if (Directory.Exists(k_TestDirectory)) + { + Directory.Delete(k_TestDirectory, true); + } + + m_DeployedContents.Clear(); + m_FailedContents.Clear(); + MockApi.Server?.ResetMappings(); + await MockApi.MockServiceAsync(new IdentityV1Mock()); + await MockApi.MockServiceAsync(new TriggersApiMock()); + Directory.CreateDirectory(k_TestDirectory); + } + + [TearDown] + public void TearDown() + { + DeleteLocalConfig(); + DeleteLocalCredentials(); + + if (Directory.Exists(k_TestDirectory)) + { + Directory.Delete(k_TestDirectory, true); + } + } + + static async Task CreateDeployTestFilesAsync(IReadOnlyList testCases, ICollection contents) + { + foreach (var testCase in testCases) + { + await File.WriteAllTextAsync(testCase.ConfigFilePath, testCase.ConfigValue); + contents.Add(testCase.DeployedContent); + } + } + + [Test] + public async Task DeployValidConfigFromDirectoryWithOptionSucceed() + { + await CreateDeployTestFilesAsync(m_DeployedTestCases, m_DeployedContents); + var deployedConfigFileString = string.Join(System.Environment.NewLine + " ", m_DeployedTestCases.Select(r => $"'{r.ConfigFilePath}'")); + await GetFullySetCli() + .DebugCommand($"deploy {k_TestDirectory} -p {CommonKeys.ValidProjectId} -e {CommonKeys.ValidEnvironmentName} -s triggers") + .AssertStandardOutputContains($"Successfully deployed the following files:{System.Environment.NewLine} {deployedConfigFileString}") + .AssertNoErrors() + .ExecuteAsync(); + } + + [Test] + public async Task DeployValidConfigFromDirectorySucceed() + { + SetConfigValue("project-id", CommonKeys.ValidProjectId); + SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); + await CreateDeployTestFilesAsync(m_DeployedTestCases, m_DeployedContents); + var deployedConfigFileString = string.Join(System.Environment.NewLine + " ", m_DeployedTestCases.Select(r => $"'{r.ConfigFilePath}'")); + await GetFullySetCli() + .DebugCommand($"deploy {k_TestDirectory} -s triggers") + .AssertStandardOutputContains($"Successfully deployed the following files:{System.Environment.NewLine} {deployedConfigFileString}") + .AssertNoErrors() + .ExecuteAsync(); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/Authoring/DeployTestsFixture.cs b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/Authoring/DeployTestsFixture.cs index 9a27726..4331e40 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/Authoring/DeployTestsFixture.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/Authoring/DeployTestsFixture.cs @@ -9,6 +9,7 @@ using Unity.Services.Cli.Authoring.Model.TableOutput; using Unity.Services.Cli.Common.Exceptions; using Unity.Services.Cli.MockServer.ServiceMocks; +using Unity.Services.DeploymentApi.Editor; namespace Unity.Services.Cli.IntegrationTest.Authoring; @@ -18,10 +19,10 @@ namespace Unity.Services.Cli.IntegrationTest.Authoring; [TestFixture] public abstract class DeployTestsFixture : UgsCliFixture { - protected static readonly string k_TestDirectory = Path.Combine(UgsCliBuilder.RootDirectory, ".tmp", "FilesDir"); + protected static readonly string TestDirectory = Path.Combine(UgsCliBuilder.RootDirectory, ".tmp", "FilesDir"); - protected List m_DeployedTestCases = new(); - protected List m_DryRunDeployedTestCases = new(); + protected readonly List DeployedTestCases = new(); + readonly List m_DryRunDeployedTestCases = new(); protected abstract AuthoringTestCase GetValidTestCase(); protected abstract AuthoringTestCase GetInvalidTestCase(); @@ -32,21 +33,21 @@ public async Task SetUp() DeleteLocalConfig(); DeleteLocalCredentials(); - if (Directory.Exists(k_TestDirectory)) + if (Directory.Exists(TestDirectory)) { - Directory.Delete(k_TestDirectory, true); + Directory.Delete(TestDirectory, true); } - Directory.CreateDirectory(k_TestDirectory); + Directory.CreateDirectory(TestDirectory); - m_DeployedTestCases.Clear(); + DeployedTestCases.Clear(); m_DryRunDeployedTestCases.Clear(); - m_MockApi.Server?.ResetMappings(); + MockApi.Server?.ResetMappings(); - await m_MockApi.MockServiceAsync(new IdentityV1Mock()); + await MockApi.MockServiceAsync(new IdentityV1Mock()); - m_DeployedTestCases.Add(GetDeployedTestCase()); + DeployedTestCases.Add(GetDeployedTestCase()); m_DryRunDeployedTestCases.Add(GetLoadedTestCase()); } @@ -76,9 +77,9 @@ public void TearDown() DeleteLocalConfig(); DeleteLocalCredentials(); - if (Directory.Exists(k_TestDirectory)) + if (Directory.Exists(TestDirectory)) { - Directory.Delete(k_TestDirectory, true); + Directory.Delete(TestDirectory, true); } } @@ -88,7 +89,7 @@ public void TearDown() public virtual async Task DeployFromEmptyDirectorySucceed() { await GetFullySetCli() - .Command($"deploy {k_TestDirectory}") + .Command($"deploy {TestDirectory}") .AssertStandardOutputContains($"No content deployed") .AssertNoErrors() .ExecuteAsync(); @@ -98,11 +99,11 @@ await GetFullySetCli() public virtual async Task DeployValidConfigFromDirectorySucceed() { var deployedContentList = - await CreateDeployTestFilesAsync(m_DeployedTestCases); + await CreateDeployTestFilesAsync(DeployedTestCases); var deployedConfigFileString = $"{Environment.NewLine} {deployedContentList[0]}"; await GetFullySetCli() - .Command($"deploy {k_TestDirectory}") + .Command($"deploy {TestDirectory}") .AssertStandardOutputContains($"Successfully deployed the following files:{deployedConfigFileString}") .AssertNoErrors() .ExecuteAsync(); @@ -121,7 +122,7 @@ await CreateDeployTestFilesAsync( $"'{invalidTestCase.ConfigFilePath}' - Status: {Statuses.FailedToRead}"; await GetFullySetCli() - .Command($"deploy {k_TestDirectory}") + .Command($"deploy {TestDirectory}") .AssertStandardOutputContains($"Failed to deploy:{Environment.NewLine} {deployedConfigFileString}") .AssertExitCode(ExitCode.HandledError) .ExecuteAsync(); @@ -130,7 +131,10 @@ await GetFullySetCli() [Test] public virtual async Task DeployValidConfigFromDirectorySucceedWithJsonOutput() { - var deployedContents = await CreateDeployTestFilesAsync(m_DeployedTestCases); + var deployedContents = await CreateDeployTestFilesAsync(DeployedTestCases); + + deployedContents[0].Status = new DeploymentStatus(Statuses.Deployed, "Created remotely"); + var logResult = CreateResult( deployedContents, Array.Empty(), @@ -140,7 +144,7 @@ public virtual async Task DeployValidConfigFromDirectorySucceedWithJsonOutput() var resultString = JsonConvert.SerializeObject(logResult.ToTable(), Formatting.Indented); await GetFullySetCli() - .Command($"deploy {k_TestDirectory} -j") + .Command($"deploy {TestDirectory} -j") .AssertStandardOutputContains(resultString) .AssertNoErrors() .ExecuteAsync(); @@ -155,6 +159,8 @@ public virtual async Task DeployConfig_DryRun() { var contentList = await CreateDeployTestFilesAsync(m_DryRunDeployedTestCases); + contentList[0].Status = new DeploymentStatus(Statuses.Deployed, "Created remotely", SeverityLevel.Success); + var logResult = new DeploymentResult( Array.Empty(), Array.Empty(), @@ -164,7 +170,7 @@ public virtual async Task DeployConfig_DryRun() true); var resultString = JsonConvert.SerializeObject(logResult.ToTable(), Formatting.Indented); await GetFullySetCli() - .Command($"deploy {k_TestDirectory} -j --dry-run") + .Command($"deploy {TestDirectory} -j --dry-run") .AssertStandardOutputContains(resultString) .AssertNoErrors() .ExecuteAsync(); @@ -177,7 +183,9 @@ await GetFullySetCli() [Test] public virtual async Task DeployWithoutReconcileWillNotDeleteRemoteFiles() { - var contentList = await CreateDeployTestFilesAsync(m_DeployedTestCases); + var contentList = await CreateDeployTestFilesAsync(DeployedTestCases); + + contentList[0].Status = new DeploymentStatus(Statuses.Deployed, "Created remotely", SeverityLevel.Success); var logResult = CreateResult( contentList, @@ -188,7 +196,7 @@ public virtual async Task DeployWithoutReconcileWillNotDeleteRemoteFiles() var resultString = JsonConvert.SerializeObject(logResult.ToTable(), Formatting.Indented); await GetFullySetCli() - .Command($"deploy {k_TestDirectory} -j") + .Command($"deploy {TestDirectory} -j") .AssertStandardOutputContains(resultString) .AssertNoErrors() .ExecuteAsync(); diff --git a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/Authoring/Fetch/CloudCodeFetchTests.cs b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/Authoring/Fetch/CloudCodeFetchTests.cs index 6216f29..e8ac842 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/Authoring/Fetch/CloudCodeFetchTests.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/Authoring/Fetch/CloudCodeFetchTests.cs @@ -93,15 +93,14 @@ public void CleanUpTestConfiguration() [SetUp] public async Task SetUp() { - await m_MockApi.MockServiceAsync(new IdentityV1Mock()); - await m_MockApi.MockServiceAsync(new CloudCodeFetchMock()); - await m_MockApi.MockServiceAsync(new LeaderboardApiMock()); + await MockApi.MockServiceAsync(new IdentityV1Mock()); + await MockApi.MockServiceAsync(new CloudCodeFetchMock()); } [TearDown] public void TearDown() { - m_MockApi.Server?.ResetMappings(); + MockApi.Server?.ResetMappings(); } static async Task CreateDeployTestFilesAsync( @@ -120,7 +119,7 @@ public async Task FetchNoConfigFromDirectorySucceed() SetConfigValue("project-id", CommonKeys.ValidProjectId); SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); await GetLoggedInCli() - .Command($"fetch {k_TestDirectory}") + .Command($"fetch {k_TestDirectory} -s cloud-code-scripts") .AssertStandardOutputContains("No content fetched") .AssertNoErrors() .ExecuteAsync(); @@ -148,10 +147,10 @@ public async Task FetchValidConfigFromDirectorySucceedWithJsonOutput(string dryR Array.Empty(), fetchedPaths, Array.Empty(), - !string.IsNullOrEmpty(dryRunOption) ); + !string.IsNullOrEmpty(dryRunOption)); var resultString = JsonConvert.SerializeObject(res.ToTable(), Formatting.Indented); await GetLoggedInCli() - .Command($"fetch {k_TestDirectory} {dryRunOption} -j") + .Command($"fetch {k_TestDirectory} {dryRunOption} -j -s cloud-code-scripts") .AssertStandardOutputContains(resultString) .AssertNoErrors() .ExecuteAsync(); diff --git a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/Authoring/Fetch/EconomyFetchTests.cs b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/Authoring/Fetch/EconomyFetchTests.cs new file mode 100644 index 0000000..8e8a965 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/Authoring/Fetch/EconomyFetchTests.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using NUnit.Framework; +using Unity.Services.Cli.Authoring.Model; +using Unity.Services.Cli.MockServer.ServiceMocks; +using Unity.Services.Gateway.EconomyApiV2.Generated.Model; + +namespace Unity.Services.Cli.IntegrationTest.Authoring.Fetch; + +public class EconomyFetchTests : FetchTestsFixture +{ + static CurrencyItemResponse s_Currency = new( + "SILVER_TOKEN", + "Silver Token", + CurrencyItemResponse.TypeEnum.CURRENCY, + 0, + 100, + "custom data", + new ModifiedMetadata(new DateTime(2023, 1, 1)), + new ModifiedMetadata(new DateTime(2023, 1, 1)) + ); + static string s_ResourceFileText = s_Currency.ToJson(); + + readonly List m_FetchedContents = new(); + + protected override AuthoringTestCase GetLocalTestCase() + { + return new AuthoringTestCase( + s_ResourceFileText, + s_Currency.Name, + $"{EconomyApiMock.ValidFileName}.ecc", + "Currency", + 100, + Statuses.Deployed, + "", + TestDirectory); + } + + protected override AuthoringTestCase GetRemoteTestCase() + { + return new AuthoringTestCase( + EconomyApiMock.Currency.ToJson(), + EconomyApiMock.Currency.Name, + $"{EconomyApiMock.Currency.Id}.ecc", + "Currency", + 100, + Statuses.Deployed, + "", + TestDirectory); + } + + [SetUp] + public new async Task SetUp() + { + m_FetchedContents.Clear(); + await MockApi + .MockServiceAsync(new EconomyApiMock()); + } + + [TestCase("", "")] + [TestCase("", "--json")] + // TODO: Remove this local test after EDX-2319 is done + public override async Task FetchEmptyDirectorySuccessfully_FetchAndCreate_WithReconcile(string dryRunOption, string jsonOption) + { + await base.FetchEmptyDirectorySuccessfully_FetchAndCreate_WithReconcile(dryRunOption, jsonOption); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/Authoring/Fetch/LeaderboardFetchTests.cs b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/Authoring/Fetch/LeaderboardFetchTests.cs index 1f2b85e..72ae1c8 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/Authoring/Fetch/LeaderboardFetchTests.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/Authoring/Fetch/LeaderboardFetchTests.cs @@ -32,9 +32,9 @@ public async Task SetUp() Directory.Delete(k_TestDirectory, true); } - m_MockApi.Server?.ResetMappings(); - await m_MockApi.MockServiceAsync(new LeaderboardApiMock()); - await m_MockApi.MockServiceAsync(new IdentityV1Mock()); + MockApi.Server?.ResetMappings(); + await MockApi.MockServiceAsync(new LeaderboardApiMock()); + await MockApi.MockServiceAsync(new IdentityV1Mock()); Directory.CreateDirectory(k_TestDirectory); m_LocalLeaderboards = new LeaderboardConfig[] { diff --git a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/Authoring/Fetch/ProjectAccessFetchTests.cs b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/Authoring/Fetch/ProjectAccessFetchTests.cs new file mode 100644 index 0000000..31031db --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/Authoring/Fetch/ProjectAccessFetchTests.cs @@ -0,0 +1,206 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using NUnit.Framework; +using Unity.Services.Cli.Authoring.Model; +using Unity.Services.Cli.Common.Exceptions; +using Unity.Services.Cli.MockServer.Common; +using Unity.Services.Cli.MockServer.ServiceMocks; +using Unity.Services.DeploymentApi.Editor; + +namespace Unity.Services.Cli.IntegrationTest.Authoring.Fetch; + +[Ignore("It's breaking the CI")] +public class ProjectAccessFetchTests : UgsCliFixture +{ + static readonly string k_TestDirectory = Path.Combine(UgsCliBuilder.RootDirectory, ".tmp", "FilesDir"); + + readonly IReadOnlyList m_FetchedTestCases = new[] + { + new AuthoringTestCase( + "{\"statements\":[{\"Sid\":\"Statement-to-be-deleted\",\"Action\":[\"*\"],\"Resource\":\"urn:ugs:cloud-code:*\",\"Principal\":\"Player\",\"Effect\":\"Deny\"}]}", + "project-statements.ac", + "Access File", + 100, + "Fetched", + null!, + k_TestDirectory, + SeverityLevel.Success) + }; + + readonly List m_FetchedContents = new(); + + [SetUp] + public async Task SetUp() + { + DeleteLocalConfig(); + DeleteLocalCredentials(); + + if (Directory.Exists(k_TestDirectory)) + { + Directory.Delete(k_TestDirectory, true); + } + + m_FetchedContents.Clear(); + MockApi.Server?.ResetMappings(); + + Directory.CreateDirectory(k_TestDirectory); + + await MockApi.MockServiceAsync(new IdentityV1Mock()); + await MockApi.MockServiceAsync(new AccessApiMock()); + } + + [TearDown] + public void TearDown() + { + DeleteLocalConfig(); + DeleteLocalCredentials(); + + if (Directory.Exists(k_TestDirectory)) + { + Directory.Delete(k_TestDirectory, true); + } + } + + static async Task CreateDeployTestFilesAsync( + IReadOnlyList testCases ,ICollection contents) + { + foreach (var testCase in testCases) + { + await File.WriteAllTextAsync(testCase.ConfigFilePath, testCase.ConfigValue); + contents.Add(testCase.DeployedContent); + } + } + + [Test] + public async Task FetchInvalidPath() + { + SetConfigValue("project-id", CommonKeys.ValidProjectId); + SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); + var invalidDirectory = Path.GetFullPath("invalid-directory"); + var expectedOutput = $"Path \"{invalidDirectory}\" could not be found."; + + await GetLoggedInCli() + .Command($"fetch {invalidDirectory}") + .AssertStandardErrorContains(expectedOutput) + .AssertExitCode(ExitCode.HandledError) + .ExecuteAsync(); + } + + [Test] + public async Task FetchValidConfigFromDirectorySucceedWithReconcile() + { + SetConfigValue("project-id", CommonKeys.ValidProjectId); + SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); + await CreateDeployTestFilesAsync(m_FetchedTestCases, m_FetchedContents); + + await GetLoggedInCli() + .Command($"fetch --services access {k_TestDirectory} --reconcile") + .AssertStandardOutput( + output => + { + Console.WriteLine(output); + StringAssert.Contains($"Successfully fetched the following files:{Environment.NewLine}", output); + StringAssert.Contains($"Created:{Environment.NewLine}", output); + StringAssert.Contains($"'statement-1' in '{k_TestDirectory}/project-statements.ac'", output); + StringAssert.Contains($"Deleted:{Environment.NewLine}", output); + StringAssert.Contains($"'Statement-to-be-deleted' in '{k_TestDirectory}/project-statements.ac'", output); + foreach (var file in m_FetchedTestCases) + { + StringAssert.Contains(file.ConfigFileName, output); + } + }).AssertNoErrors() + .ExecuteAsync(); + } + + [Test] + public async Task FetchValidConfigFromDirectorySucceedWithoutReconcile() + { + SetConfigValue("project-id", CommonKeys.ValidProjectId); + SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); + await CreateDeployTestFilesAsync(m_FetchedTestCases, m_FetchedContents); + + await GetLoggedInCli() + .Command($"fetch --services access {k_TestDirectory}") + .AssertStandardOutput( + output => + { + Console.WriteLine(output); + StringAssert.Contains($"Successfully fetched the following files:{Environment.NewLine}", output); + StringAssert.Contains($"Deleted:{Environment.NewLine}", output); + StringAssert.Contains($"'Statement-to-be-deleted' in '{k_TestDirectory}/project-statements.ac'", output); + StringAssert.DoesNotContain($"Created:{Environment.NewLine}", output); + StringAssert.DoesNotContain($"'statement-1' in '{k_TestDirectory}/project-statements.ac'", output); + foreach (var file in m_FetchedTestCases) + { + StringAssert.Contains(file.ConfigFileName, output); + } + }).AssertNoErrors() + .ExecuteAsync(); + } + + [Test] + public async Task FetchValidConfigFromDirectorySucceedWithDryRun() + { + SetConfigValue("project-id", CommonKeys.ValidProjectId); + SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); + await CreateDeployTestFilesAsync(m_FetchedTestCases, m_FetchedContents); + + await GetLoggedInCli() + .Command($"fetch --services access {k_TestDirectory} --dry-run") + .AssertStandardOutput( + output => + { + Console.WriteLine(output); + StringAssert.Contains($"This is a Dry Run. The result below is the expected result for this operation.{Environment.NewLine}", output); + StringAssert.Contains($"Will delete:{Environment.NewLine}", output); + StringAssert.Contains($"'Statement-to-be-deleted' in '{k_TestDirectory}/project-statements.ac'", output); + StringAssert.DoesNotContain($"Will create:{Environment.NewLine}", output); + StringAssert.DoesNotContain($"'statement-1' in '{k_TestDirectory}/project-statements.ac'", output); + foreach (var file in m_FetchedTestCases) + { + StringAssert.Contains(file.ConfigFileName, output); + } + }).AssertNoErrors() + .ExecuteAsync(); + } + + [Test] + public async Task FetchValidConfigFromDirectorySucceedWithDryRunAndReconcile() + { + SetConfigValue("project-id", CommonKeys.ValidProjectId); + SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); + await CreateDeployTestFilesAsync(m_FetchedTestCases, m_FetchedContents); + + await GetLoggedInCli() + .Command($"fetch --services access {k_TestDirectory} --dry-run --reconcile") + .AssertStandardOutput( + output => + { + Console.WriteLine(output); + StringAssert.Contains($"This is a Dry Run. The result below is the expected result for this operation.{Environment.NewLine}", output); + StringAssert.Contains($"Will delete:{Environment.NewLine}", output); + StringAssert.Contains($"'Statement-to-be-deleted' in '{k_TestDirectory}/project-statements.ac'", output); + StringAssert.Contains($"Will create:{Environment.NewLine}", output); + StringAssert.Contains($"'statement-1' in '{k_TestDirectory}/project-statements.ac'", output); + foreach (var file in m_FetchedTestCases) + { + StringAssert.Contains(file.ConfigFileName, output); + } + }).AssertNoErrors() + .ExecuteAsync(); + } + + [Test] + public async Task FetchNoConfigsFromDirectorySucceed() + { + SetConfigValue("project-id", CommonKeys.ValidProjectId); + SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); + await GetLoggedInCli() + .Command($"fetch --services access {k_TestDirectory}") + .AssertStandardOutputContains("No content fetched") + .AssertNoErrors() + .ExecuteAsync(); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/Authoring/Fetch/RemoteConfigFetchTests.cs b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/Authoring/Fetch/RemoteConfigFetchTests.cs index 43045b9..ada799b 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/Authoring/Fetch/RemoteConfigFetchTests.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/Authoring/Fetch/RemoteConfigFetchTests.cs @@ -71,13 +71,12 @@ public async Task SetUp() } m_FetchedContents.Clear(); - m_MockApi.Server?.ResetMappings(); + MockApi.Server?.ResetMappings(); Directory.CreateDirectory(k_TestDirectory); - await m_MockApi.MockServiceAsync(new IdentityV1Mock()); - await m_MockApi.MockServiceAsync(new RemoteConfigMock()); - await m_MockApi.MockServiceAsync(new LeaderboardApiMock()); + await MockApi.MockServiceAsync(new IdentityV1Mock()); + await MockApi.MockServiceAsync(new RemoteConfigMock()); } [TearDown] @@ -111,7 +110,7 @@ public async Task FetchInvalidPath() var expectedOutput = $"Path \"{invalidDirectory}\" could not be found."; await GetLoggedInCli() - .Command($"fetch {invalidDirectory}") + .Command($"fetch {invalidDirectory} -s remote-config") .AssertStandardErrorContains(expectedOutput) .AssertExitCode(ExitCode.HandledError) .ExecuteAsync(); @@ -125,7 +124,7 @@ public async Task FetchValidConfigFromDirectorySucceed() await CreateDeployTestFilesAsync(m_FetchedTestCases, m_FetchedContents); await GetLoggedInCli() - .Command($"fetch {k_TestDirectory}") + .Command($"fetch {k_TestDirectory} -s remote-config") .AssertStandardOutput( output => { @@ -145,7 +144,7 @@ public async Task FetchNoConfigFromDirectorySucceed() SetConfigValue("project-id", CommonKeys.ValidProjectId); SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); await GetLoggedInCli() - .Command($"fetch {k_TestDirectory}") + .Command($"fetch {k_TestDirectory} -s remote-config") .AssertStandardOutputContains("No content fetched") .AssertNoErrors() .ExecuteAsync(); @@ -155,7 +154,7 @@ await GetLoggedInCli() public async Task FetchNoConfigFromDirectoryWithOptionSucceed() { await GetLoggedInCli() - .Command($"fetch {k_TestDirectory} -p {CommonKeys.ValidProjectId} -e {CommonKeys.ValidEnvironmentName}") + .Command($"fetch {k_TestDirectory} -p {CommonKeys.ValidProjectId} -e {CommonKeys.ValidEnvironmentName} -s remote-config") .AssertStandardOutputContains($"No content fetched") .AssertNoErrors() .ExecuteAsync(); @@ -180,7 +179,7 @@ public async Task FetchValidConfigFromDirectorySucceedWithJsonOutput() false); var resultString = JsonConvert.SerializeObject(logResult.ToTable(), Formatting.Indented); await GetLoggedInCli() - .Command($"fetch {k_TestDirectory} -j") + .Command($"fetch {k_TestDirectory} -j -s remote-config") .AssertStandardOutputContains(resultString) .AssertNoErrors() .ExecuteAsync(); diff --git a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/Authoring/Fetch/TriggersFetchTests.cs b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/Authoring/Fetch/TriggersFetchTests.cs new file mode 100644 index 0000000..fd546cc --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/Authoring/Fetch/TriggersFetchTests.cs @@ -0,0 +1,208 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Newtonsoft.Json; +using NUnit.Framework; +using Unity.Services.Cli.MockServer.ServiceMocks; +using Unity.Services.Cli.Triggers.Deploy; +using Unity.Services.DeploymentApi.Editor; +using Unity.Services.Triggers.Authoring.Core.Model; +using Statuses = Unity.Services.Cli.Authoring.Model.Statuses; +using Unity.Services.Triggers.Authoring.Core.Validations; + +namespace Unity.Services.Cli.IntegrationTest.Authoring.Fetch; + +[Ignore("Excluding fetch from the release")] +public class TriggersFetchTests : UgsCliFixture +{ + static readonly string k_TestDirectory = + Path.Combine(UgsCliBuilder.RootDirectory, ".tmp", "FilesDir"); + TriggerConfig[] m_LocalTriggers = null!; + TriggerConfig[] m_RemoteTriggers = null!; + + [SetUp] + public async Task SetUp() + { + DeleteLocalConfig(); + DeleteLocalCredentials(); + + if (Directory.Exists(k_TestDirectory)) + { + Directory.Delete(k_TestDirectory, true); + } + + MockApi.Server?.ResetMappings(); + await MockApi.MockServiceAsync(new TriggersApiMock()); + await MockApi.MockServiceAsync(new IdentityV1Mock()); + Directory.CreateDirectory(k_TestDirectory); + m_LocalTriggers = new TriggerConfig[] + { + new ("00000000-0000-0000-0000-000000000001", "Trigger1", "EventType1", "ActionType1", "ActionUrn1") + { + Path = Path.Combine(k_TestDirectory, "Trigger1.tr") + } + }; + + m_RemoteTriggers = new TriggerConfig[] + { + new ("00000000-0000-0000-0000-000000000001", "Trigger1", "EventType1", "ActionType1", "ActionUrn1") + { + Name = "Trigger1", + Path = Path.Combine(k_TestDirectory, "Trigger1.tr") + }, + new ("00000000-0000-0000-0000-000000000002", "Trigger2", "EventType2", "ActionType2", "ActionUrn2") + { + Name = "Trigger2", + Path = Path.Combine(k_TestDirectory, "Trigger2.tr") + } + }; + } + + [TearDown] + public void TearDown() + { + DeleteLocalConfig(); + DeleteLocalCredentials(); + + if (Directory.Exists(k_TestDirectory)) + { + Directory.Delete(k_TestDirectory, true); + } + } + + static async Task CreateDeployFileAsync(IReadOnlyList testCases) + { + var file = new TriggersConfigFile() + { + Configs = testCases + .Select(c => new TriggerConfig( + c.Name, c.EventType, c.ActionType, c.ActionUrn)) + .ToList() + }; + var serialized = JsonConvert.SerializeObject(file); + + await File.WriteAllTextAsync(Path.Combine(k_TestDirectory, "Trigger1.tr"), serialized); + } + + [Test] + public async Task FetchToValidConfigFromDirectorySucceeds() + { + var localTriggers = m_LocalTriggers!; + await CreateDeployFileAsync(localTriggers); + var expectedResult = new TriggersFetchResult( + updated: new IDeploymentItem[]{ localTriggers[0] }, + deleted: Array.Empty(), + created: Array.Empty(), + authored: new IDeploymentItem[]{new TriggersFileItem(null!, localTriggers[0].Path)}, + failed: Array.Empty() + ); + await GetFullySetCli() + .DebugCommand($"fetch {k_TestDirectory} -s triggers") + .AssertStandardOutputContains(expectedResult.ToString()) + .AssertNoErrors() + .ExecuteAsync(); + } + + [Test] + public async Task FetchToValidConfigFromDirectoryReconcileSucceeds() + { + var localTriggers = m_LocalTriggers!; + await CreateDeployFileAsync(localTriggers); + var expectedResult = new TriggersFetchResult( + updated: new IDeploymentItem[]{ localTriggers[0] }, + deleted: Array.Empty(), + created: new IDeploymentItem[]{ m_RemoteTriggers![1] }, + authored: new IDeploymentItem[] + { + new TriggersFileItem(null!, localTriggers[0].Path), + new TriggersFileItem(null!, m_RemoteTriggers[1].Path) + }, + failed: Array.Empty() + ); + await GetFullySetCli() + .DebugCommand($"fetch {k_TestDirectory} --reconcile -s triggers") + .AssertStandardOutputContains(expectedResult.ToString()) + .AssertNoErrors() + .ExecuteAsync(); + } + + [Test] + public async Task FetchToValidConfigFromDirectoryDryRunSucceeds() + { + var localTriggers = m_LocalTriggers!; + await CreateDeployFileAsync(localTriggers); + var expectedResult = new TriggersFetchResult( + updated: new IDeploymentItem[]{ localTriggers[0] }, + deleted: Array.Empty(), + created: Array.Empty(), + authored: new IDeploymentItem[]{new TriggersFileItem(null!, localTriggers[0].Path)}, + failed: Array.Empty(), + dryRun: true + ); + + var ex = expectedResult.ToString(); + await GetFullySetCli() + .DebugCommand($"fetch {k_TestDirectory} --dry-run -s triggers") + .AssertStandardOutputContains(ex) + .AssertNoErrors() + .ExecuteAsync(); + } + + [Test] + public async Task FetchToValidConfigFromDirectoryDryRunWithReconcileSucceeds() + { + var localTriggers = m_LocalTriggers!; + await CreateDeployFileAsync(localTriggers); + var expectedResult = new TriggersFetchResult( + updated: new IDeploymentItem[]{ localTriggers[0] }, + deleted: Array.Empty(), + created: new IDeploymentItem[]{ m_RemoteTriggers![1] }, + authored: new IDeploymentItem[] + { + new TriggersFileItem(null!, localTriggers[0].Path), + new TriggersFileItem(null!, m_RemoteTriggers[1].Path) + }, + failed: Array.Empty(), + dryRun: true + ); + await GetFullySetCli() + .DebugCommand($"fetch {k_TestDirectory} --reconcile --dry-run -s triggers") + .AssertStandardOutputContains(expectedResult.ToString()) + .AssertNoErrors() + .ExecuteAsync(); + } + + [Test] + public async Task FetchToValidConfigFromDuplicateIdFails() + { + var localTriggers = m_LocalTriggers!.Append( + new TriggerConfig("00000000-0000-0000-0000-000000000001", "Trigger1", "EventType1", "ActionType1", "ActionUrn1") + { + Path = Path.Combine(k_TestDirectory, "Trigger1.tr") + }).ToList(); + await CreateDeployFileAsync(localTriggers); + + foreach (var tr in localTriggers) + { + var failedMessage1 = + DuplicateResourceValidation.GetDuplicateResourceErrorMessages(tr, localTriggers); + var failedStatus = Statuses.GetFailedToFetch(failedMessage1.Item2); + tr.Status = failedStatus; + } + + await CreateDeployFileAsync(localTriggers); + var expectedResult = new TriggersFetchResult( + updated: Array.Empty(), + deleted: Array.Empty(), + created: Array.Empty(), + authored: Array.Empty(), + failed: new IDeploymentItem[]{ localTriggers[0], localTriggers[1] } + ); + await GetFullySetCli() + .DebugCommand($"fetch {k_TestDirectory} -s triggers") + .AssertStandardOutputContains(expectedResult.ToString()) + .ExecuteAsync(); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/Authoring/FetchTestsFixture.cs b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/Authoring/FetchTestsFixture.cs index 2f6ad16..4607474 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/Authoring/FetchTestsFixture.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/Authoring/FetchTestsFixture.cs @@ -18,8 +18,8 @@ namespace Unity.Services.Cli.IntegrationTest.Authoring; [TestFixture] public abstract class FetchTestsFixture : UgsCliFixture { - protected static readonly string k_TestDirectory = Path.Combine(UgsCliBuilder.RootDirectory, ".tmp", "FilesDir"); - protected List m_FetchedTestCases = new(); + protected static readonly string TestDirectory = Path.Combine(UgsCliBuilder.RootDirectory, ".tmp", "FilesDir"); + readonly List m_FetchedTestCases = new(); protected abstract AuthoringTestCase GetLocalTestCase(); protected abstract AuthoringTestCase GetRemoteTestCase(); @@ -30,27 +30,28 @@ public async Task SetUp() DeleteLocalConfig(); DeleteLocalCredentials(); - if (Directory.Exists(k_TestDirectory)) + if (Directory.Exists(TestDirectory)) { - Directory.Delete(k_TestDirectory, true); + Directory.Delete(TestDirectory, true); } - Directory.CreateDirectory(k_TestDirectory); + Directory.CreateDirectory(TestDirectory); m_FetchedTestCases.Clear(); - m_MockApi.Server?.ResetMappings(); + MockApi.Server?.ResetMappings(); - await m_MockApi.MockServiceAsync(new IdentityV1Mock()); - await m_MockApi.MockServiceAsync(new LeaderboardApiMock()); + await MockApi.MockServiceAsync(new IdentityV1Mock()); + await MockApi.MockServiceAsync(new LeaderboardApiMock()); + await MockApi.MockServiceAsync(new TriggersApiMock()); } - public static AuthoringTestCase SetTestCase(AuthoringTestCase testCase, string status) + public static AuthoringTestCase SetTestCase(AuthoringTestCase testCase, string status, string detail = "") { var type = testCase.DeployedContent.Type; testCase.DeployedContent = new DeployContent( - testCase.ConfigFileName, type, testCase.ConfigFilePath, 100, status, ""); + testCase.ConfigFileName, type, testCase.ConfigFilePath, 100, status, detail); return testCase; } @@ -60,9 +61,9 @@ public void TearDown() DeleteLocalConfig(); DeleteLocalCredentials(); - if (Directory.Exists(k_TestDirectory)) + if (Directory.Exists(TestDirectory)) { - Directory.Delete(k_TestDirectory, true); + Directory.Delete(TestDirectory, true); } } @@ -93,7 +94,7 @@ public virtual async Task FetchNoConfigFromDirectorySucceed(string dryRunOption, jsonOption }); await GetFullySetCli() - .Command($"fetch {k_TestDirectory} {dryRunOption} {jsonOption}") + .Command($"fetch {TestDirectory} {dryRunOption} {jsonOption}") .AssertStandardOutputContains(resultString) .AssertNoErrors() .ExecuteAsync(); @@ -107,7 +108,7 @@ public virtual async Task FetchValidConfigFromDirectoryWithOptionSucceed(string var fetchedContent = await CreateFetchTestFilesAsync(m_FetchedTestCases); var resultString = FormatDefaultOutput(fetchedContent, !string.IsNullOrEmpty(dryRunOption)); await GetLoggedInCli() - .Command($"fetch {k_TestDirectory} -p {CommonKeys.ValidProjectId} -e {CommonKeys.ValidEnvironmentName} {dryRunOption}") + .Command($"fetch {TestDirectory} -p {CommonKeys.ValidProjectId} -e {CommonKeys.ValidEnvironmentName} {dryRunOption}") .AssertStandardOutputContains(resultString) .AssertNoErrors() .ExecuteAsync(); @@ -120,7 +121,7 @@ await GetLoggedInCli() public virtual async Task FetchValidConfigFromDirectorySuccessfully_FetchAndDelete(string dryRunOption, string jsonOption) { //Case: Fetch files successfully but no existing file in service so deletes local files - m_FetchedTestCases.Add(SetTestCase(GetLocalTestCase(), Statuses.Deleted)); + m_FetchedTestCases.Add(SetTestCase(GetLocalTestCase(), Statuses.Fetched, "Deleted locally")); var fetchedContent = await CreateFetchTestFilesAsync(m_FetchedTestCases); var resultString = FormatOutput(fetchedContent, new List() @@ -130,7 +131,7 @@ public virtual async Task FetchValidConfigFromDirectorySuccessfully_FetchAndDele }); await GetFullySetCli() - .Command($"fetch {k_TestDirectory} {dryRunOption} {jsonOption}") + .Command($"fetch {TestDirectory} {dryRunOption} {jsonOption}") .AssertStandardOutputContains(resultString) .AssertNoErrors() .ExecuteAsync(); @@ -143,7 +144,7 @@ await GetFullySetCli() public virtual async Task FetchValidConfigFromDirectorySuccessfully_FetchAndUpdate(string dryRunOption, string jsonOption) { //Case: Fetch files successfully and updates local files - m_FetchedTestCases.Add(SetTestCase(GetRemoteTestCase(), Statuses.Updated)); + m_FetchedTestCases.Add(SetTestCase(GetRemoteTestCase(), Statuses.Fetched, "Updated locally")); var fetchedContent = await CreateFetchTestFilesAsync(m_FetchedTestCases); var resultString = FormatOutput(fetchedContent, new List() { @@ -152,7 +153,7 @@ public virtual async Task FetchValidConfigFromDirectorySuccessfully_FetchAndUpda }); await GetFullySetCli() - .Command($"fetch {k_TestDirectory} {dryRunOption} {jsonOption}") + .Command($"fetch {TestDirectory} {dryRunOption} {jsonOption}") .AssertStandardOutputContains(resultString) .AssertNoErrors() .ExecuteAsync(); @@ -165,7 +166,7 @@ await GetFullySetCli() public virtual async Task FetchEmptyDirectorySuccessfully_FetchAndCreate_WithReconcile(string dryRunOption, string jsonOption) { //Case: Fetch files successfully and create local files - m_FetchedTestCases.Add(SetTestCase(GetRemoteTestCase(), Statuses.Created)); + m_FetchedTestCases.Add(SetTestCase(GetRemoteTestCase(), Statuses.Fetched, "Created locally")); List fetchedContent = new(); foreach (var testCase in m_FetchedTestCases) { @@ -177,7 +178,7 @@ public virtual async Task FetchEmptyDirectorySuccessfully_FetchAndCreate_WithRec jsonOption }); await GetFullySetCli() - .Command($"fetch {k_TestDirectory} {dryRunOption} {jsonOption} --reconcile -s economy") + .Command($"fetch {TestDirectory} {dryRunOption} {jsonOption} --reconcile -s economy") .AssertStandardOutputContains(resultString) .AssertNoErrors() .ExecuteAsync(); @@ -275,15 +276,15 @@ protected static string FormatJsonOutput(List deployContentList, static FetchResult GetFetchResult(List deployContentList, bool isDryRun) { var updatedContent = deployContentList - .Where(t => string.Equals(t.Status.Message, Statuses.Updated)) + .Where(t => string.Equals(t.Status.MessageDetail, "Updated locally")) .ToArray(); var deletedContent = deployContentList - .Where(t => string.Equals(t.Status.Message, Statuses.Deleted)) + .Where(t => string.Equals(t.Status.MessageDetail, "Deleted locally")) .ToArray(); var createdContent = deployContentList - .Where(t => string.Equals(t.Status.Message, Statuses.Created)) + .Where(t => string.Equals(t.Status.MessageDetail, "Created locally")) .ToArray(); var fetchedContent = deployContentList diff --git a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/CloudCodeTests/CloudCodeModuleTests.cs b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/CloudCodeTests/CloudCodeModuleTests.cs index 99add8e..60e87b6 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/CloudCodeTests/CloudCodeModuleTests.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/CloudCodeTests/CloudCodeModuleTests.cs @@ -31,14 +31,14 @@ public async Task SetUp() DeleteLocalConfig(); DeleteLocalCredentials(); - await m_MockApi.MockServiceAsync(new IdentityV1Mock()); - await m_MockApi.MockServiceAsync(new CloudCodeV1Mock()); + await MockApi.MockServiceAsync(new IdentityV1Mock()); + await MockApi.MockServiceAsync(new CloudCodeV1Mock()); } [TearDown] public void TearDown() { - m_MockApi.Server?.ResetMappings(); + MockApi.Server?.ResetMappings(); } // cloud-code module list tests diff --git a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/CloudCodeTests/CloudCodeScriptTests.cs b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/CloudCodeTests/CloudCodeScriptTests.cs index f786a8d..20f438e 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/CloudCodeTests/CloudCodeScriptTests.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/CloudCodeTests/CloudCodeScriptTests.cs @@ -30,8 +30,7 @@ const string k_ImportTestFileDirectory + " Please login using the 'ugs login' command."; const string k_EnvironmentNameNotSetErrorMessage = "'environment-name' is not set in project configuration." + " '" + Keys.EnvironmentKeys.EnvironmentName + "' is not set in system environment variables."; - const string k_UnauthorizedFileAccessErrorMessage = "Make sure that the CLI has the permissions to access" - + " the file and that the specified path points to a file and not a directory."; + const string k_UnauthorizedFileAccessErrorMessage = "The path passed is not a valid file path, please review it and try again."; [SetUp] public async Task SetUp() @@ -40,15 +39,15 @@ public async Task SetUp() DeleteLocalCredentials(); if (!Directory.Exists(k_TestDirectory)) Directory.CreateDirectory(k_TestDirectory); - await m_MockApi.MockServiceAsync(new IdentityV1Mock()); - await m_MockApi.MockServiceAsync(new CloudCodeV1Mock()); + await MockApi.MockServiceAsync(new IdentityV1Mock()); + await MockApi.MockServiceAsync(new CloudCodeV1Mock()); } [TearDown] public void TearDown() { File.Delete(k_ValidFilepath); - m_MockApi.Server?.ResetMappings(); + MockApi.Server?.ResetMappings(); } // cloud-code list tests diff --git a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/Common/UgsCliFixture.cs b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/Common/UgsCliFixture.cs index 570dc9f..03d458d 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/Common/UgsCliFixture.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/Common/UgsCliFixture.cs @@ -33,14 +33,14 @@ public abstract class UgsCliFixture /// protected string CredentialsFile => m_IntegrationConfig.CredentialsFile; - protected readonly MockApi m_MockApi = new(NetworkTargetEndpoints.MockServer); + protected readonly MockApi MockApi = new(NetworkTargetEndpoints.MockServer); readonly IntegrationConfig m_IntegrationConfig = new(); [OneTimeTearDown] public void DisposeMockServer() { - m_MockApi.Server?.Dispose(); + MockApi.Server?.Dispose(); } [OneTimeTearDown] public void DisposeIntegrationConfig() diff --git a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/Common/UgsCliTestCase.ExternalProcess.cs b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/Common/UgsCliTestCase.ExternalProcess.cs index f3e5c2b..ff60fa6 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/Common/UgsCliTestCase.ExternalProcess.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/Common/UgsCliTestCase.ExternalProcess.cs @@ -1,3 +1,4 @@ +using System; using System.Diagnostics; using System.IO; using System.Threading; @@ -9,17 +10,34 @@ public partial class UgsCliTestCase { class ExternalProcess : IProcess { + const int k_Timeout = 30; + public ExternalProcess(Process innerProcess) { InnerProcess = innerProcess; + StandardOutput = null!; + StandardError = null!; } public Process InnerProcess { get; } public bool HasExited => InnerProcess.HasExited; - public Task WaitForExitAsync(CancellationToken cancellationToken = default) + public async Task WaitForExitAsync(CancellationToken cancellationToken = default) { - return InnerProcess.WaitForExitAsync(cancellationToken); + try + { + StandardOutput = InnerProcess.StandardOutput; + StandardError = InnerProcess.StandardError; + + await InnerProcess + .WaitForExitAsync(cancellationToken) + .WaitAsync(TimeSpan.FromSeconds(k_Timeout), cancellationToken); + } + catch (TimeoutException) + { + InnerProcess.Kill(); + StandardError = InnerProcess.StandardOutput; + } } public int ExitCode => InnerProcess.ExitCode; @@ -36,7 +54,7 @@ public void Dispose() } public StreamWriter StandardInput => InnerProcess.StandardInput; - public StreamReader StandardOutput => InnerProcess.StandardOutput; - public StreamReader StandardError => InnerProcess.StandardError; + public StreamReader StandardOutput { get; private set; } + public StreamReader StandardError { get; private set; } } } diff --git a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/EconomyTests/EconomyPublishTests.cs b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/EconomyTests/EconomyPublishTests.cs new file mode 100644 index 0000000..383dec7 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/EconomyTests/EconomyPublishTests.cs @@ -0,0 +1,173 @@ +using System.Threading.Tasks; +using NUnit.Framework; +using Unity.Services.Cli.Common.Exceptions; +using Unity.Services.Cli.IntegrationTest.Common; +using Unity.Services.Cli.MockServer.Common; +using Unity.Services.Cli.MockServer.ServiceMocks; + +namespace Unity.Services.Cli.IntegrationTest.EconomyTests; + +public class EconomyPublishTests : UgsCliFixture +{ + const string k_NotLoggedInOutput = + " You are not logged into any service account. Please login using the 'ugs login' command."; + const string k_MissingProjectIdOutput = "'project-id' is not set in project configuration"; + const string k_MissingEnvironmentNameOutput = "'environment-name' is not set in project configuration"; + + [SetUp] + public async Task SetUp() + { + DeleteLocalConfig(); + DeleteLocalCredentials(); + + await MockApi.MockServiceAsync(new EconomyApiMock()); + await MockApi.MockServiceAsync(new IdentityV1Mock()); + } + + [TearDown] + public void TearDown() + { + DeleteLocalConfig(); + DeleteLocalCredentials(); + MockApi.Server?.ResetMappings(); + } + + #region get-published + + [Test] + public async Task GetPublished_ThrowsWhenNotAuthenticated() + { + SetConfigValue("project-id", CommonKeys.ValidProjectId); + SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); + + await new UgsCliTestCase() + .Command("economy get-published") + .AssertExitCode(ExitCode.HandledError) + .AssertStandardErrorContains(k_NotLoggedInOutput) + .ExecuteAsync(); + } + + [Test] + public async Task GetPublished_ThrowsWithProjectIdMissing() + { + SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); + + await new UgsCliTestCase() + .Command($"economy get-published") + .AssertExitCode(ExitCode.HandledError) + .AssertStandardErrorContains(k_MissingProjectIdOutput) + .ExecuteAsync(); + } + + [Test] + public async Task GetPublished_ThrowsWithProjectIdEmpty() + { + SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); + + await new UgsCliTestCase() + .Command($"economy get-published --project-id \"\"") + .AssertExitCode(ExitCode.HandledError) + .AssertStandardErrorContains(k_MissingProjectIdOutput) + .ExecuteAsync(); + } + + [Test] + public async Task GetPublished_ThrowsWithEnvironmentNameMissing() + { + SetConfigValue("project-id", CommonKeys.ValidProjectId); + + await new UgsCliTestCase() + .Command($"economy get-published") + .AssertExitCode(ExitCode.HandledError) + .AssertStandardErrorContains(k_MissingEnvironmentNameOutput) + .ExecuteAsync(); + } + + [Test] + public async Task GetPublished_SucceedsWithValidInput() + { + SetConfigValue("project-id", CommonKeys.ValidProjectId); + SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); + + await GetLoggedInCli() + .Command($"economy get-published") + .AssertNoErrors() + .ExecuteAsync(); + } + + [Test] + public async Task GetPublished_SucceedsWithValidInput_JsonFormattedOutput() + { + SetConfigValue("project-id", CommonKeys.ValidProjectId); + SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); + + await GetLoggedInCli() + .Command($"economy get-published -j") + .AssertNoErrors() + .ExecuteAsync(); + } + #endregion + + #region publish + [Test] + public async Task Publish_ThrowsWhenNotAuthenticated() + { + SetConfigValue("project-id", CommonKeys.ValidProjectId); + SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); + + await new UgsCliTestCase() + .Command("economy publish") + .AssertExitCode(ExitCode.HandledError) + .AssertStandardErrorContains(k_NotLoggedInOutput) + .ExecuteAsync(); + } + + [Test] + public async Task Publish_ThrowsWithProjectIdMissing() + { + SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); + + await new UgsCliTestCase() + .Command($"economy publish") + .AssertExitCode(ExitCode.HandledError) + .AssertStandardErrorContains(k_MissingProjectIdOutput) + .ExecuteAsync(); + } + + [Test] + public async Task Publish_ThrowsWithProjectIdEmpty() + { + SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); + + await new UgsCliTestCase() + .Command($"economy publish --project-id \"\"") + .AssertExitCode(ExitCode.HandledError) + .AssertStandardErrorContains(k_MissingProjectIdOutput) + .ExecuteAsync(); + } + + [Test] + public async Task Publish_ThrowsWithEnvironmentNameMissing() + { + SetConfigValue("project-id", CommonKeys.ValidProjectId); + + await new UgsCliTestCase() + .Command($"economy publish") + .AssertExitCode(ExitCode.HandledError) + .AssertStandardErrorContains(k_MissingEnvironmentNameOutput) + .ExecuteAsync(); + } + + [Test] + public async Task Publish_SucceedsWithValidInput() + { + SetConfigValue("project-id", CommonKeys.ValidProjectId); + SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); + + await GetLoggedInCli() + .Command($"economy publish") + .ExecuteAsync(); + } + + #endregion +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/EconomyTests/EconomyResourceTests.cs b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/EconomyTests/EconomyResourceTests.cs new file mode 100644 index 0000000..2c4f3d8 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/EconomyTests/EconomyResourceTests.cs @@ -0,0 +1,161 @@ +using System.Threading.Tasks; +using NUnit.Framework; +using Unity.Services.Cli.Common.Exceptions; +using Unity.Services.Cli.IntegrationTest.Common; +using Unity.Services.Cli.MockServer.Common; +using Unity.Services.Cli.MockServer.ServiceMocks; + +namespace Unity.Services.Cli.IntegrationTest.EconomyTests; + +public class EconomyResourceTests : UgsCliFixture +{ + const string k_NotLoggedInOutput = + " You are not logged into any service account. Please login using the 'ugs login' command."; + + const string k_MissingProjectIdOutput = "'project-id' is not set in project configuration"; + const string k_MissingEnvironmentNameOutput = "'environment-name' is not set in project configuration"; + + [SetUp] + public async Task SetUp() + { + DeleteLocalConfig(); + DeleteLocalCredentials(); + + await MockApi.MockServiceAsync(new EconomyApiMock()); + await MockApi.MockServiceAsync(new IdentityV1Mock()); + } + + [TearDown] + public void TearDown() + { + DeleteLocalConfig(); + DeleteLocalCredentials(); + MockApi.Server?.ResetMappings(); + } + + #region get-resources + + [Test] + public async Task Get_ThrowsWhenNotAuthenticated() + { + SetConfigValue("project-id", CommonKeys.ValidProjectId); + SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); + + await new UgsCliTestCase() + .Command("economy get-resources") + .AssertExitCode(ExitCode.HandledError) + .AssertStandardErrorContains(k_NotLoggedInOutput) + .ExecuteAsync(); + } + + [Test] + public async Task Get_ThrowsWithProjectIdMissing() + { + SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); + + await new UgsCliTestCase() + .Command($"economy get-resources") + .AssertExitCode(ExitCode.HandledError) + .AssertStandardErrorContains(k_MissingProjectIdOutput) + .ExecuteAsync(); + } + + [Test] + public async Task Get_ThrowsWithProjectIdEmpty() + { + SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); + + await new UgsCliTestCase() + .Command($"economy get-resources --project-id \"\"") + .AssertExitCode(ExitCode.HandledError) + .AssertStandardErrorContains(k_MissingProjectIdOutput) + .ExecuteAsync(); + } + + [Test] + public async Task Get_ThrowsWithEnvironmentNameMissing() + { + SetConfigValue("project-id", CommonKeys.ValidProjectId); + + await new UgsCliTestCase() + .Command($"economy get-resources") + .AssertExitCode(ExitCode.HandledError) + .AssertStandardErrorContains(k_MissingEnvironmentNameOutput) + .ExecuteAsync(); + } + + [Test] + public async Task Get_SucceedsWithValidInput() + { + SetConfigValue("project-id", CommonKeys.ValidProjectId); + SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); + + await GetLoggedInCli() + .Command($"economy get-resources") + .AssertNoErrors() + .ExecuteAsync(); + } + #endregion + + #region delete resource + [Test] + public async Task Delete_ThrowsWhenNotAuthenticated() + { + SetConfigValue("project-id", CommonKeys.ValidProjectId); + SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); + + await new UgsCliTestCase() + .Command("economy delete SWORD") + .AssertExitCode(ExitCode.HandledError) + .AssertStandardErrorContains(k_NotLoggedInOutput) + .ExecuteAsync(); + } + + [Test] + public async Task Delete_ThrowsWithProjectIdMissing() + { + SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); + + await new UgsCliTestCase() + .Command($"economy delete SWORD") + .AssertExitCode(ExitCode.HandledError) + .AssertStandardErrorContains(k_MissingProjectIdOutput) + .ExecuteAsync(); + } + + [Test] + public async Task Delete_ThrowsWithProjectIdEmpty() + { + SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); + + await new UgsCliTestCase() + .Command($"economy delete SWORD --project-id \"\"") + .AssertExitCode(ExitCode.HandledError) + .AssertStandardErrorContains(k_MissingProjectIdOutput) + .ExecuteAsync(); + } + + [Test] + public async Task Delete_ThrowsWithEnvironmentNameMissing() + { + SetConfigValue("project-id", CommonKeys.ValidProjectId); + + await new UgsCliTestCase() + .Command($"economy delete SWORD") + .AssertExitCode(ExitCode.HandledError) + .AssertStandardErrorContains(k_MissingEnvironmentNameOutput) + .ExecuteAsync(); + } + + [Test] + public async Task Delete_SucceedsWithValidInput() + { + SetConfigValue("project-id", CommonKeys.ValidProjectId); + SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); + + await GetLoggedInCli() + .Command($"economy delete SWORD") + .ExecuteAsync(); + } + #endregion +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/EconomyTests/Resources/invalidCurrencyDefinition_BadValueType.ecc b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/EconomyTests/Resources/invalidCurrencyDefinition_BadValueType.ecc new file mode 100644 index 0000000..53bdb5f --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/EconomyTests/Resources/invalidCurrencyDefinition_BadValueType.ecc @@ -0,0 +1,8 @@ +{ + "id": "GOLD", + "name": "Gold", + "type": "CURRENCY", + "initial": 10, + "max": "abc", + "customData": null +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/EconomyTests/Resources/invalidCurrencyDefinition_MissingField.ecc b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/EconomyTests/Resources/invalidCurrencyDefinition_MissingField.ecc new file mode 100644 index 0000000..8c058e0 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/EconomyTests/Resources/invalidCurrencyDefinition_MissingField.ecc @@ -0,0 +1,7 @@ +{ + "name": "Gold", + "type": "CURRENCY", + "initial": 10, + "max": 1000, + "customData": null +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/EconomyTests/Resources/invalidCurrencyDefinition_NegativeInitialAmount.ecc b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/EconomyTests/Resources/invalidCurrencyDefinition_NegativeInitialAmount.ecc new file mode 100644 index 0000000..f153c14 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/EconomyTests/Resources/invalidCurrencyDefinition_NegativeInitialAmount.ecc @@ -0,0 +1,8 @@ +{ + "id": "GOLD", + "name": "Gold", + "type": "CURRENCY", + "initial": -10, + "max": 90000, + "customData": null +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/EconomyTests/Resources/invalidInventoryDefinition_MissingField.eci b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/EconomyTests/Resources/invalidInventoryDefinition_MissingField.eci new file mode 100644 index 0000000..7ca1f8d --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/EconomyTests/Resources/invalidInventoryDefinition_MissingField.eci @@ -0,0 +1,5 @@ +{ + "name": "Sword", + "type": "INVENTORY_ITEM", + "customData": null +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/EconomyTests/Resources/invalidResourceDefinition_BadType.ecc b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/EconomyTests/Resources/invalidResourceDefinition_BadType.ecc new file mode 100644 index 0000000..0d48ffc --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/EconomyTests/Resources/invalidResourceDefinition_BadType.ecc @@ -0,0 +1,8 @@ +{ + "id": "GOLD", + "name": "Gold", + "type": "SOME_FAKE_TYPE", + "initial": 10, + "max": 1000, + "customData": null +} diff --git a/Samples/Deploy/Economy/resource.ec b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/EconomyTests/Resources/validCurrencyDefinition.ecc similarity index 100% rename from Samples/Deploy/Economy/resource.ec rename to Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/EconomyTests/Resources/validCurrencyDefinition.ecc diff --git a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/EconomyTests/Resources/validInventoryDefinition.eci b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/EconomyTests/Resources/validInventoryDefinition.eci new file mode 100644 index 0000000..d05880d --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/EconomyTests/Resources/validInventoryDefinition.eci @@ -0,0 +1,6 @@ +{ + "id": "SWORD", + "name": "Sword", + "type": "INVENTORY_ITEM", + "customData": null +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/EconomyTests/Resources/validRealMoneyPurchaseDefinition.ecr b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/EconomyTests/Resources/validRealMoneyPurchaseDefinition.ecr new file mode 100644 index 0000000..af6e90f --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/EconomyTests/Resources/validRealMoneyPurchaseDefinition.ecr @@ -0,0 +1,16 @@ +{ + "id": "BUY_GOLD", + "name": "Buy Gold", + "type": "MONEY_PURCHASE", + "customData": null, + "rewards": [ + { + "resourceId": "GOLD", + "amount": 100 + } + ], + "storeIdentifiers": { + "appleAppStore": "test-apple", + "googlePlayStore": "test-google" + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/EconomyTests/Resources/validVirtualPurchaseDefinition.ecv b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/EconomyTests/Resources/validVirtualPurchaseDefinition.ecv new file mode 100644 index 0000000..9ca0e14 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/EconomyTests/Resources/validVirtualPurchaseDefinition.ecv @@ -0,0 +1,18 @@ +{ + "id": "BUY_SWORD", + "name": "Buy Sword", + "type": "VIRTUAL_PURCHASE", + "customData": null, + "costs": [ + { + "resourceId": "GOLD", + "amount": 5 + } + ], + "rewards": [ + { + "resourceId": "SWORD", + "amount": 1 + } + ] +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/EconomyTests/Resources/validVirtualPurchaseDefinition_FreeGift.ecv b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/EconomyTests/Resources/validVirtualPurchaseDefinition_FreeGift.ecv new file mode 100644 index 0000000..925caf1 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/EconomyTests/Resources/validVirtualPurchaseDefinition_FreeGift.ecv @@ -0,0 +1,14 @@ +{ + "id": "BUY_SWORD", + "name": "Buy Sword", + "type": "VIRTUAL_PURCHASE", + "customData": null, + "costs": [ + ], + "rewards": [ + { + "resourceId": "SWORD", + "amount": 1 + } + ] +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/EnvTests/EnvTests.cs b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/EnvTests/EnvTests.cs index 8e76463..d1b777f 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/EnvTests/EnvTests.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/EnvTests/EnvTests.cs @@ -24,7 +24,7 @@ public async Task SetUp() DeleteLocalConfig(); DeleteLocalCredentials(); - await m_MockApi.MockServiceAsync(new IdentityV1Mock()); + await MockApi.MockServiceAsync(new IdentityV1Mock()); } // env list tests diff --git a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/GameServerHostingTests/GameServerHostingBuildListTests.cs b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/GameServerHostingTests/GameServerHostingBuildListTests.cs index 466777c..50d5d68 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/GameServerHostingTests/GameServerHostingBuildListTests.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/GameServerHostingTests/GameServerHostingBuildListTests.cs @@ -41,7 +41,7 @@ public async Task BuildList_ThrowsNotLoggedInException() SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); await new UgsCliTestCase() - .Command(k_BuildCreateCommand) + .Command(k_BuildListCommand) .AssertExitCode(ExitCode.HandledError) .AssertStandardErrorContains(k_NotLoggedIn) .ExecuteAsync(); diff --git a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/GameServerHostingTests/GameServerHostingMachineListTest.cs b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/GameServerHostingTests/GameServerHostingMachineListTest.cs new file mode 100644 index 0000000..358d103 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/GameServerHostingTests/GameServerHostingMachineListTest.cs @@ -0,0 +1,83 @@ +using System.Threading.Tasks; +using NUnit.Framework; +using Unity.Services.Cli.Common.Exceptions; +using Unity.Services.Cli.IntegrationTest.Common; +using Unity.Services.Cli.MockServer.Common; +using Unity.Services.Cli.MockServer.ServiceMocks.GameServerHosting; + +namespace Unity.Services.Cli.IntegrationTest.GameServerHostingTests; + +public partial class GameServerHostingTests +{ + static readonly string k_MachineListCommand = $"gsh machine list"; + + [Test] + [Category("gsh")] + [Category("gsh machine")] + [Category("gsh machine list")] + [Ignore("Failing with feature flag")] + public async Task MachineList_Succeeds() + { + await GetFullySetCli() + .Command(k_MachineListCommand) + .AssertStandardOutput( + // ReSharper disable once ParameterOnlyUsedForPreconditionCheck.Local + str => + { + Assert.IsTrue(str.Contains("Fetching machine list...")); + Assert.IsTrue(str.Contains("Id: 654321")); + Assert.IsTrue(str.Contains("name: p-gce-test-2")); + }) + .AssertNoErrors() + .ExecuteAsync(); + } + + [Test] + [Category("gsh")] + [Category("gsh machine")] + [Category("gsh machine list")] + [Ignore("Failing with feature flag")] + public async Task MachineList_ThrowsNotLoggedInException() + { + SetConfigValue("project-id", CommonKeys.ValidProjectId); + SetConfigValue("environment-id", CommonKeys.ValidEnvironmentId); + SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); + + await new UgsCliTestCase() + .Command(k_MachineListCommand) + .AssertExitCode(ExitCode.HandledError) + .AssertStandardErrorContains(k_NotLoggedIn) + .ExecuteAsync(); + } + + [Test] + [Category("gsh")] + [Category("gsh machine")] + [Category("gsh machine list")] + [Ignore("Failing with feature flag")] + public async Task MachineList_ThrowsProjectIdNotSetException() + { + SetConfigValue("environment-id", CommonKeys.ValidEnvironmentId); + SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); + await GetLoggedInCli() + .Command(k_MachineListCommand) + .AssertExitCode(ExitCode.HandledError) + .AssertStandardErrorContains(k_ProjectIdIsNotSet) + .ExecuteAsync(); + } + + [Test] + [Category("gsh")] + [Category("gsh machine")] + [Category("gsh machine list")] + [Ignore("Failing with feature flag")] + public async Task MachineList_ThrowsEnvironmentIdNotSetException() + { + SetConfigValue("project-id", CommonKeys.ValidProjectId); + await GetLoggedInCli() + .Command(k_MachineListCommand) + .AssertExitCode(ExitCode.HandledError) + .AssertStandardErrorContains(k_EnvironmentNameIsNotSet) + .ExecuteAsync(); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/GameServerHostingTests/GameServerHostingServerFilesTests.cs b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/GameServerHostingTests/GameServerHostingServerFilesTests.cs new file mode 100644 index 0000000..4f05ca6 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/GameServerHostingTests/GameServerHostingServerFilesTests.cs @@ -0,0 +1,79 @@ +using System.Threading.Tasks; +using NUnit.Framework; +using Unity.Services.Cli.Common.Exceptions; +using Unity.Services.Cli.IntegrationTest.Common; +using Unity.Services.Cli.MockServer.Common; + +namespace Unity.Services.Cli.IntegrationTest.GameServerHostingTests; + +public partial class GameServerHostingTests +{ + [Test] + [Category("gsh")] + [Category("gsh server")] + [Category("gsh server files")] + [Ignore("Failing with feature flag")] + public async Task ServerFiles_Succeeds() + { + await GetFullySetCli() + .Command("gsh server files 123") + .AssertStandardOutput( + str => + { + Assert.IsTrue(str.Contains("Fetching server files...")); + Assert.IsTrue(str.Contains("fileName: server.log")); + }) + .AssertNoErrors() + .ExecuteAsync(); + } + + [Test] + [Category("gsh")] + [Category("gsh server")] + [Category("gsh server files")] + [Ignore("Failing with feature flag")] + public async Task ServerFiles_ThrowsNotLoggedInException() + { + SetConfigValue("project-id", CommonKeys.ValidProjectId); + SetConfigValue("environment-id", CommonKeys.ValidEnvironmentId); + SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); + + await new UgsCliTestCase() + .Command("gsh server files") + .AssertExitCode(ExitCode.HandledError) + .AssertStandardErrorContains(k_NotLoggedIn) + .ExecuteAsync(); + } + + [Test] + [Category("gsh")] + [Category("gsh server")] + [Category("gsh server files")] + [Ignore("Failing with feature flag")] + public async Task ServerFiles_ThrowsProjectIdNotSetException() + { + SetConfigValue("environment-id", CommonKeys.ValidEnvironmentId); + SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); + await GetLoggedInCli() + .Command("gsh server files") + .AssertExitCode(ExitCode.HandledError) + .AssertStandardErrorContains(k_ProjectIdIsNotSet) + .ExecuteAsync(); + } + + [Test] + [Category("gsh")] + [Category("gsh server")] + [Category("gsh server files")] + [Ignore("Failing with feature flag")] + public async Task ServerFiles_ThrowsEnvironmentIdNotSetException() + { + SetConfigValue("project-id", CommonKeys.ValidProjectId); + SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); + await GetLoggedInCli() + .Command("gsh server files") + .AssertExitCode(ExitCode.HandledError) + .AssertStandardErrorContains(k_EnvironmentNameIsNotSet) + .ExecuteAsync(); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/GameServerHostingTests/GameServerHostingTests.cs b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/GameServerHostingTests/GameServerHostingTests.cs index 954d4de..2a2fe9e 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/GameServerHostingTests/GameServerHostingTests.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/GameServerHostingTests/GameServerHostingTests.cs @@ -7,23 +7,22 @@ namespace Unity.Services.Cli.IntegrationTest.GameServerHostingTests; -[Ignore("Disable until fixed by GHS")] public partial class GameServerHostingTests : UgsCliFixture { [OneTimeSetUp] public async Task OneTimeSetUp() { - m_MockApi.Server?.AllowPartialMapping(); - await m_MockApi.MockServiceAsync(new IdentityV1Mock()); - await m_MockApi.MockServiceAsync(new GameServerHostingApiMock()); + MockApi.Server?.AllowPartialMapping(); + await MockApi.MockServiceAsync(new IdentityV1Mock()); + await MockApi.MockServiceAsync(new GameServerHostingApiMock()); CreateTempFiles(); } [OneTimeTearDown] public void OneTimeTearDown() { - m_MockApi.Server?.Dispose(); - m_MockApi.Server?.ResetMappings(); + MockApi.Server?.Dispose(); + MockApi.Server?.ResetMappings(); DeleteTempFiles(); } diff --git a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/LeaderboardTests/LeaderboardTests.cs b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/LeaderboardTests/LeaderboardTests.cs index 4729abd..6999d86 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/LeaderboardTests/LeaderboardTests.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/LeaderboardTests/LeaderboardTests.cs @@ -17,10 +17,10 @@ public class LeaderboardTests : UgsCliFixture static readonly string k_TestDirectory = Path.Combine(UgsCliBuilder.RootDirectory, ".tmp/FilesDir"); static readonly string k_LeaderboardFileName = "foo"; static readonly string k_MissingFieldLeaderboardFileName = "missing"; - static readonly string k_brokenFile = "broken"; + const string k_BrokenFile = "broken"; - static readonly string k_defaultFileName = "ugs.lbzip"; - static readonly string k_alternateFileName = "other.lbzip"; + const string k_DefaultFileName = "ugs.lbzip"; + const string k_AlternateFileName = "other.lbzip"; [OneTimeTearDown] public void OneTimeTearDown() @@ -45,11 +45,11 @@ public async Task SetUp() Directory.CreateDirectory(k_TestDirectory); await File.WriteAllTextAsync(Path.Join(k_TestDirectory, k_LeaderboardFileName), JsonConvert.SerializeObject(LeaderboardApiMock.Leaderboard1)); await File.WriteAllTextAsync(Path.Join(k_TestDirectory, k_MissingFieldLeaderboardFileName), "{ \"id\": \"lb1\", \"name\": \"leaderboard 1\" }"); - await File.WriteAllTextAsync(Path.Join(k_TestDirectory, k_brokenFile), "{"); + await File.WriteAllTextAsync(Path.Join(k_TestDirectory, k_BrokenFile), "{"); - m_MockApi.Server?.ResetMappings(); - await m_MockApi.MockServiceAsync(new IdentityV1Mock()); - await m_MockApi.MockServiceAsync(new LeaderboardApiMock()); + MockApi.Server?.ResetMappings(); + await MockApi.MockServiceAsync(new IdentityV1Mock()); + await MockApi.MockServiceAsync(new LeaderboardApiMock()); } [Test] @@ -181,8 +181,8 @@ public async Task LeaderboardResetSucceed() [Test] public async Task LeaderboardImportSucceed() { - ZipArchiver m_zipArchiver = new ZipArchiver(); - await m_zipArchiver.ZipAsync(Path.Join(k_TestDirectory, k_defaultFileName), "test", new[] + ZipArchiver zipArchiver = new ZipArchiver(); + await zipArchiver.ZipAsync(Path.Join(k_TestDirectory, k_DefaultFileName), "test", new[] { LeaderboardApiMock.Leaderboard1, LeaderboardApiMock.Leaderboard2, LeaderboardApiMock.Leaderboard3, LeaderboardApiMock.Leaderboard4, LeaderboardApiMock.Leaderboard5, LeaderboardApiMock.Leaderboard6, LeaderboardApiMock.Leaderboard7, LeaderboardApiMock.Leaderboard8, LeaderboardApiMock.Leaderboard9, @@ -195,11 +195,11 @@ await m_zipArchiver.ZipAsync(Path.Join(k_TestDirectory, k_defaultFileName), "tes [Test] public async Task LeaderboardImportWithNameSucceed() { - ZipArchiver m_zipArchiver = new ZipArchiver(); - await m_zipArchiver.ZipAsync(Path.Join(k_TestDirectory, k_alternateFileName), "test", new[] { LeaderboardApiMock.Leaderboard1, LeaderboardApiMock.Leaderboard2, LeaderboardApiMock.Leaderboard3, LeaderboardApiMock.Leaderboard4, LeaderboardApiMock.Leaderboard5, LeaderboardApiMock.Leaderboard6, LeaderboardApiMock.Leaderboard7, LeaderboardApiMock.Leaderboard8, LeaderboardApiMock.Leaderboard9, LeaderboardApiMock.Leaderboard10, LeaderboardApiMock.Leaderboard11, LeaderboardApiMock.Leaderboard12 }); + ZipArchiver zipArchiver = new ZipArchiver(); + await zipArchiver.ZipAsync(Path.Join(k_TestDirectory, k_AlternateFileName), "test", new[] { LeaderboardApiMock.Leaderboard1, LeaderboardApiMock.Leaderboard2, LeaderboardApiMock.Leaderboard3, LeaderboardApiMock.Leaderboard4, LeaderboardApiMock.Leaderboard5, LeaderboardApiMock.Leaderboard6, LeaderboardApiMock.Leaderboard7, LeaderboardApiMock.Leaderboard8, LeaderboardApiMock.Leaderboard9, LeaderboardApiMock.Leaderboard10, LeaderboardApiMock.Leaderboard11, LeaderboardApiMock.Leaderboard12 }); var expectedMessage = "Importing configs..."; - await AssertSuccess($"leaderboards import {k_TestDirectory} {k_alternateFileName}", expectedResult: expectedMessage); + await AssertSuccess($"leaderboards import {k_TestDirectory} {k_AlternateFileName}", expectedResult: expectedMessage); } [Test] @@ -213,16 +213,16 @@ public async Task LeaderboardExportSucceed() public async Task LeaderboardExportWithNameSucceed() { var expectedMessage = "Exporting your environment..."; - await AssertSuccess($"leaderboards export {k_TestDirectory} {k_alternateFileName}", expectedResult: expectedMessage); + await AssertSuccess($"leaderboards export {k_TestDirectory} {k_AlternateFileName}", expectedResult: expectedMessage); } [Test] public async Task LeaderboardExportWithSameNameSucceed() { var expectedMessage = "Exporting your environment..."; - await AssertSuccess($"leaderboards export {k_TestDirectory} {k_alternateFileName}", expectedResult: expectedMessage); + await AssertSuccess($"leaderboards export {k_TestDirectory} {k_AlternateFileName}", expectedResult: expectedMessage); var errorMessage = "The filename to export to already exists. Please create a new file"; - await AssertException($"leaderboards export {k_TestDirectory} {k_alternateFileName}", errorMessage); + await AssertException($"leaderboards export {k_TestDirectory} {k_AlternateFileName}", errorMessage); } static async Task AssertSuccess(string command, string? expectedMessage = null, string? expectedResult = null) { diff --git a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/LobbyTests/LobbyTests.cs b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/LobbyTests/LobbyTests.cs index ceece06..7ebeeb6 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/LobbyTests/LobbyTests.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/LobbyTests/LobbyTests.cs @@ -29,15 +29,15 @@ public class LobbyTests : UgsCliFixture [OneTimeSetUp] public async Task OneTimeSetUp() { - m_MockApi.Server?.AllowPartialMapping(); - await m_MockApi.MockServiceAsync(new IdentityV1Mock()); - await m_MockApi.MockServiceAsync(new LobbyApiMock()); + MockApi.Server?.AllowPartialMapping(); + await MockApi.MockServiceAsync(new IdentityV1Mock()); + await MockApi.MockServiceAsync(new LobbyApiMock()); } [OneTimeTearDown] public void OneTimeTearDown() { - m_MockApi.Server?.Dispose(); + MockApi.Server?.Dispose(); } [SetUp] diff --git a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/NewFile/NewFileTests.cs b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/NewFile/NewFileTests.cs index 04dc9c9..1d4703e 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/NewFile/NewFileTests.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/NewFile/NewFileTests.cs @@ -34,6 +34,9 @@ public void TearDown() [TestCase("remote-config", k_RemoteConfigFileExtension)] [TestCase("cloud-code scripts", k_CloudCodeFileExtension)] +#if FEATURE_ECONOMY + [TestCase("economy", k_EconomyFileExtension)] +#endif public async Task NewFileCreatedWithNoErrorsAndCorrectOutput(string fullParentCommand, string serviceExtension) { var newFileOutPutString = $"[Information]: {Environment.NewLine} Config file {k_NewFileBaseName}{serviceExtension} created successfully!{Environment.NewLine}"; diff --git a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/PlayerTests/PlayerTests.cs b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/PlayerTests/PlayerTests.cs index cbd4753..18223d7 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/PlayerTests/PlayerTests.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/PlayerTests/PlayerTests.cs @@ -20,7 +20,7 @@ public class PlayerTests : UgsCliFixture [OneTimeSetUp] public void OneTimeSetUp() { - m_MockApi.Server?.AllowPartialMapping(); + MockApi.Server?.AllowPartialMapping(); } [SetUp] @@ -28,7 +28,7 @@ public async Task SetUp() { DeleteLocalConfig(); DeleteLocalCredentials(); - await m_MockApi.MockServiceAsync(new PlayerApiMock()); + await MockApi.MockServiceAsync(new PlayerApiMock()); } [Test] diff --git a/Unity.Services.Cli/Unity.Services.Cli.Leaderboards.UnitTest/Deploy/LeaderboardDeploymentServiceTests.cs b/Unity.Services.Cli/Unity.Services.Cli.Leaderboards.UnitTest/Deploy/LeaderboardDeploymentServiceTests.cs index c14a5f9..b2f5b5c 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.Leaderboards.UnitTest/Deploy/LeaderboardDeploymentServiceTests.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.Leaderboards.UnitTest/Deploy/LeaderboardDeploymentServiceTests.cs @@ -28,11 +28,11 @@ public void SetUp() var lb1 = new LeaderboardConfig("lb1", "LB1"); var lb2 = new LeaderboardConfig("lb2", "LB2"); - var mockLoad = Task.FromResult( - (IReadOnlyList)(new[] - { - lb1 - })); + var mockLoad = (IReadOnlyList) new[] + { + lb1 + }; + var failedToLoad = (IReadOnlyList)Array.Empty(); m_MockLeaderboardConfigLoader .Setup( @@ -41,7 +41,7 @@ public void SetUp() It.IsAny>(), It.IsAny()) ) - .Returns(mockLoad); + .ReturnsAsync((mockLoad, failedToLoad)); var deployResult = new DeployResult() { @@ -85,4 +85,40 @@ public async Task DeployAsync_MapsResult() Assert.AreEqual(1, res.Deployed.Count); Assert.AreEqual(0, res.Failed.Count); } + + [Test] + public async Task DeployAsync_MapsFailed() + { + var lb1 = new LeaderboardConfig("lb1", "LB1"); + var lbFailed = new LeaderboardConfig("failed_lb", "Failed"); + var mockLoad = (IReadOnlyList) new[] { lb1 }; + var failedToLoad = (IReadOnlyList) new[] { lbFailed }; + + m_MockLeaderboardConfigLoader + .Setup( + m => + m.LoadConfigsAsync( + It.IsAny>(), + It.IsAny()) + ) + .ReturnsAsync((mockLoad, failedToLoad)); + + var input = new DeployInput() + { + CloudProjectId = string.Empty + }; + var res = await m_DeploymentService!.Deploy( + input, + new[] { "dir" }, + string.Empty, + string.Empty, + null, + CancellationToken.None); + + Assert.AreEqual(1, res.Created.Count); + Assert.AreEqual(0, res.Updated.Count); + Assert.AreEqual(0, res.Deleted.Count); + Assert.AreEqual(1, res.Deployed.Count); + Assert.AreEqual(1, res.Failed.Count); + } } diff --git a/Unity.Services.Cli/Unity.Services.Cli.Leaderboards.UnitTest/Deploy/LeaderboardFetchServiceTests.cs b/Unity.Services.Cli/Unity.Services.Cli.Leaderboards.UnitTest/Deploy/LeaderboardFetchServiceTests.cs index 1f03d69..e408c3d 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.Leaderboards.UnitTest/Deploy/LeaderboardFetchServiceTests.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.Leaderboards.UnitTest/Deploy/LeaderboardFetchServiceTests.cs @@ -35,11 +35,8 @@ public void SetUp() var lb1 = new LeaderboardConfig("lb1", "LB1"); var lb2 = new LeaderboardConfig("lb2", "LB2"); - var mockLoad = Task.FromResult( - (IReadOnlyList)(new[] - { - lb1 - })); + var mockLoad = (IReadOnlyList) new[] { lb1 }; + var failedToLoad = (IReadOnlyList)Array.Empty(); m_MockLeaderboardConfigLoader .Setup( @@ -48,7 +45,7 @@ public void SetUp() It.IsAny>(), It.IsAny()) ) - .Returns(mockLoad); + .ReturnsAsync((mockLoad, failedToLoad)); var deployResult = new FetchResult() { @@ -82,6 +79,8 @@ public async Task FetchAsync_MapsResult() var res = await m_FetchService!.FetchAsync( input, new[] { "dir" }, + string.Empty, + string.Empty, null, CancellationToken.None); @@ -91,4 +90,41 @@ public async Task FetchAsync_MapsResult() Assert.AreEqual(1, res.Fetched.Count); Assert.AreEqual(0, res.Failed.Count); } + + [Test] + public async Task FetchAsync_MapsFailed() + { + var lb1 = new LeaderboardConfig("lb1", "LB1"); + var lbFailed = new LeaderboardConfig("failed_lb", "Failed"); + var mockLoad = (IReadOnlyList) new[] { lb1 }; + var failedToLoad = (IReadOnlyList) new[] { lbFailed }; + + m_MockLeaderboardConfigLoader + .Setup( + m => + m.LoadConfigsAsync( + It.IsAny>(), + It.IsAny()) + ) + .ReturnsAsync((mockLoad, failedToLoad)); + + var input = new FetchInput() + { + Path = "dir", + CloudProjectId = string.Empty + }; + var res = await m_FetchService!.FetchAsync( + input, + new[] { "dir" }, + string.Empty, + string.Empty, + null, + CancellationToken.None); + + Assert.AreEqual(1, res.Created.Count); + Assert.AreEqual(0, res.Updated.Count); + Assert.AreEqual(0, res.Deleted.Count); + Assert.AreEqual(1, res.Fetched.Count); + Assert.AreEqual(1, res.Failed.Count); + } } diff --git a/Unity.Services.Cli/Unity.Services.Cli.Leaderboards.UnitTest/Deploy/LeaderboardsConfigLoaderTests.cs b/Unity.Services.Cli/Unity.Services.Cli.Leaderboards.UnitTest/Deploy/LeaderboardsConfigLoaderTests.cs index ec21fc1..eeddd0a 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.Leaderboards.UnitTest/Deploy/LeaderboardsConfigLoaderTests.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.Leaderboards.UnitTest/Deploy/LeaderboardsConfigLoaderTests.cs @@ -55,7 +55,9 @@ public async Task ConfigLoader_Deserializes() var configs = await m_LeaderboardsConfigLoader .LoadConfigsAsync(new[] { "path" }, CancellationToken.None); - var config = configs.First(); + Assert.AreEqual(1, configs.Loaded.Count); + Assert.AreEqual(0, configs.Failed.Count); + var config = configs.Loaded.First(); Assert.AreEqual("path", config.Id); Assert.AreEqual(SortOrder.Asc, config.SortOrder); @@ -66,4 +68,24 @@ public async Task ConfigLoader_Deserializes() Assert.AreEqual("Silver", config.TieringConfig.Tiers[1].Id); Assert.AreEqual("Bronze", config.TieringConfig.Tiers[2].Id); } + + [Test] + public async Task ConfigLoader_ReportsFailures() + { + m_LeaderboardsConfigLoader = new LeaderboardsConfigLoader( + m_FileSystem.Object); + var content = @"{ + 'SortOrder': 'asc', + 'UpdateType': 'keepBest', + 'Name': 'My Complex LB', + 'BucketSize': 'hi'"; + m_FileSystem.Setup(f => f.ReadAllText(It.IsAny(), It.IsAny())) + .ReturnsAsync(content); + + var configs = await m_LeaderboardsConfigLoader + .LoadConfigsAsync(new[] { "path" }, CancellationToken.None); + + Assert.AreEqual(0, configs.Loaded.Count); + Assert.AreEqual(1, configs.Failed.Count); + } } diff --git a/Unity.Services.Cli/Unity.Services.Cli.Leaderboards/Deploy/ILeaderboardsConfigLoader.cs b/Unity.Services.Cli/Unity.Services.Cli.Leaderboards/Deploy/ILeaderboardsConfigLoader.cs index cc760c8..d7ac790 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.Leaderboards/Deploy/ILeaderboardsConfigLoader.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.Leaderboards/Deploy/ILeaderboardsConfigLoader.cs @@ -4,7 +4,7 @@ namespace Unity.Services.Cli.Leaderboards.Deploy; public interface ILeaderboardsConfigLoader { - Task> LoadConfigsAsync( + Task<(IReadOnlyList Loaded,IReadOnlyList Failed)> LoadConfigsAsync( IReadOnlyCollection paths, CancellationToken cancellationToken); } diff --git a/Unity.Services.Cli/Unity.Services.Cli.Leaderboards/Deploy/LeaderboardsConfigLoader.cs b/Unity.Services.Cli/Unity.Services.Cli.Leaderboards/Deploy/LeaderboardsConfigLoader.cs index c634509..f792b19 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.Leaderboards/Deploy/LeaderboardsConfigLoader.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.Leaderboards/Deploy/LeaderboardsConfigLoader.cs @@ -14,9 +14,10 @@ public LeaderboardsConfigLoader(IFileSystem fileSystem) m_FileSystem = fileSystem; } - public async Task> LoadConfigsAsync(IReadOnlyCollection paths, CancellationToken cancellationToken) + public async Task<(IReadOnlyList Loaded,IReadOnlyList Failed)> LoadConfigsAsync(IReadOnlyCollection paths, CancellationToken cancellationToken) { var leaderboards = new List(); + var failedToLoad = new List(); var serializationSettings = LeaderboardConfigFile.GetSerializationSettings(); foreach (var path in paths) { @@ -32,6 +33,7 @@ public async Task> LoadConfigsAsync(IReadOnlyCo lb = FromFile(leaderboardConfigFile, path); lb.Status = new DeploymentStatus("Loaded"); + leaderboards.Add(lb); } catch (Exception ex) { @@ -39,11 +41,11 @@ public async Task> LoadConfigsAsync(IReadOnlyCo "Failed to Load", $"Error reading file: {ex.Message}", SeverityLevel.Error); + failedToLoad.Add(lb); } - leaderboards.Add(lb); } - return leaderboards; + return (leaderboards, failedToLoad); } static LeaderboardConfig FromFile(LeaderboardConfigFile config, string path) diff --git a/Unity.Services.Cli/Unity.Services.Cli.Leaderboards/Deploy/LeaderboardsDeploymentService.cs b/Unity.Services.Cli/Unity.Services.Cli.Leaderboards/Deploy/LeaderboardsDeploymentService.cs index 083e560..9d8c512 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.Leaderboards/Deploy/LeaderboardsDeploymentService.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.Leaderboards/Deploy/LeaderboardsDeploymentService.cs @@ -2,6 +2,7 @@ using Unity.Services.Cli.Authoring.Input; using Unity.Services.Cli.Authoring.Model; using Unity.Services.Cli.Authoring.Service; +using Unity.Services.DeploymentApi.Editor; using Unity.Services.Leaderboards.Authoring.Core.Deploy; using Unity.Services.Leaderboards.Authoring.Core.Model; using Unity.Services.Leaderboards.Authoring.Core.Service; @@ -30,9 +31,12 @@ public LeaderboardDeploymentService( m_DeployFileExtension = ".lb"; } - string IDeploymentService.ServiceType => m_ServiceType; - string IDeploymentService.ServiceName => m_ServiceName; - string IDeploymentService.DeployFileExtension => m_DeployFileExtension; + public string ServiceType => m_ServiceType; + public string ServiceName => m_ServiceName; + public IReadOnlyList FileExtensions => new[] + { + m_DeployFileExtension + }; public async Task Deploy( DeployInput deployInput, @@ -47,7 +51,7 @@ public async Task Deploy( var files = await m_LeaderboardsConfigLoader.LoadConfigsAsync(filePaths, cancellationToken); var deployStatusList = await m_DeploymentHandler.DeployAsync( - files, + files.Loaded, deployInput.DryRun, deployInput.Reconcile, cancellationToken); @@ -57,6 +61,6 @@ public async Task Deploy( deployStatusList.Deleted, deployStatusList.Created, deployStatusList.Deployed, - deployStatusList.Failed); + deployStatusList.Failed.Concat(files.Failed).Cast().ToList()); } } diff --git a/Unity.Services.Cli/Unity.Services.Cli.Leaderboards/Deploy/LeaderboardsFetchService.cs b/Unity.Services.Cli/Unity.Services.Cli.Leaderboards/Deploy/LeaderboardsFetchService.cs index 3a89b3c..56d0d54 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.Leaderboards/Deploy/LeaderboardsFetchService.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.Leaderboards/Deploy/LeaderboardsFetchService.cs @@ -2,6 +2,7 @@ using Unity.Services.Cli.Authoring.Input; using Unity.Services.Cli.Authoring.Service; using Unity.Services.Cli.Common.Utils; +using Unity.Services.DeploymentApi.Editor; using Unity.Services.Leaderboards.Authoring.Core.Fetch; using Unity.Services.Leaderboards.Authoring.Core.Service; using FetchResult = Unity.Services.Cli.Authoring.Model.FetchResult; @@ -36,20 +37,28 @@ public LeaderboardFetchService( m_FileExtension = ".lb"; } - string IFetchService.ServiceType => m_ServiceType; - string IFetchService.ServiceName => m_ServiceName; - string IFetchService.FileExtension => m_FileExtension; - public async Task FetchAsync(FetchInput input, IReadOnlyList filePaths, StatusContext? loadingContext, CancellationToken cancellationToken) + public string ServiceType => m_ServiceType; + public string ServiceName => m_ServiceName; + public IReadOnlyList FileExtensions => new[] { - var environmentId = await m_UnityEnvironment.FetchIdentifierAsync(cancellationToken); - m_Client.Initialize(environmentId, input.CloudProjectId!, cancellationToken); + m_FileExtension + }; + public async Task FetchAsync( + FetchInput input, + IReadOnlyList filePaths, + string projectId, + string environmentId, + StatusContext? loadingContext, + CancellationToken cancellationToken) + { + m_Client.Initialize(environmentId, projectId, cancellationToken); var leaderboards = await m_LeaderboardsConfigLoader .LoadConfigsAsync(filePaths, cancellationToken); var deployStatusList = await m_FetchHandler.FetchAsync( input.Path, - leaderboards, + leaderboards.Loaded, input.DryRun, input.Reconcile, cancellationToken); @@ -59,6 +68,6 @@ public async Task FetchAsync(FetchInput input, IReadOnlyList().ToList()); } } diff --git a/Unity.Services.Cli/Unity.Services.Cli.Leaderboards/IO/FileSystem.cs b/Unity.Services.Cli/Unity.Services.Cli.Leaderboards/IO/FileSystem.cs index f4225d3..a9cfe30 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.Leaderboards/IO/FileSystem.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.Leaderboards/IO/FileSystem.cs @@ -2,21 +2,4 @@ namespace Unity.Services.Cli.Leaderboards.IO; -public class FileSystem : IFileSystem -{ - public Task ReadAllText(string path, CancellationToken token = default(CancellationToken)) - { - return File.ReadAllTextAsync(path, token); - } - - public Task WriteAllText(string path, string contents, CancellationToken token = default(CancellationToken)) - { - return File.WriteAllTextAsync(path, contents, token); - } - - public Task Delete(string path, CancellationToken token = default(CancellationToken)) - { - File.Delete(path); - return Task.CompletedTask; - } -} +class FileSystem : Common.IO.FileSystem, IFileSystem { } diff --git a/Unity.Services.Cli/Unity.Services.Cli.Leaderboards/Service/LeaderboardsService.cs b/Unity.Services.Cli/Unity.Services.Cli.Leaderboards/Service/LeaderboardsService.cs index 1ec60b1..a1ebb96 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.Leaderboards/Service/LeaderboardsService.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.Leaderboards/Service/LeaderboardsService.cs @@ -23,6 +23,7 @@ public LeaderboardsService(ILeaderboardsApiAsync leaderboardsApiAsync, IConfigur m_ConfigValidator = validator; m_AuthenticationService = authenticationService; } + public async Task> GetLeaderboardsAsync(string projectId, string environmentId, string? cursor, int? limit, CancellationToken cancellationToken = default) { await AuthorizeServiceAsync(cancellationToken); diff --git a/Unity.Services.Cli/Unity.Services.Cli.Lobby.UnitTest/LobbyModuleTests.cs b/Unity.Services.Cli/Unity.Services.Cli.Lobby.UnitTest/LobbyModuleTests.cs index e363c8f..a308b48 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.Lobby.UnitTest/LobbyModuleTests.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.Lobby.UnitTest/LobbyModuleTests.cs @@ -38,8 +38,7 @@ public CommandTestCase(string name, List arguments, List