From 5f95e87971811ddc4cea0bc2a8367e02e405e1e6 Mon Sep 17 00:00:00 2001 From: operate-services-sdk-bot Date: Wed, 12 Jun 2024 19:18:31 +0000 Subject: [PATCH] Release v1.5.0 --- CHANGELOG.md | 12 +- Samples/Deploy/Leaderboards/lbsample.lb | 2 +- .../Deploy/Matchmaker/environment-config.mme | 5 + Samples/Deploy/Matchmaker/queue.mmq | 67 ++ Samples/Deploy/instructions.md | 29 + Third Party Notices.md | 37 + .../Deploy/ProjectAccessClientTests.cs | 4 +- .../Service/AccessServiceTests.cs | 10 +- .../Unity.Services.Cli.Access.UnitTest.csproj | 2 +- .../Utils/TestMocks.cs | 20 +- .../Deploy/ProjectAccessClient.cs | 2 +- .../Service/AccessService.cs | 4 +- .../Unity.Services.Cli.Access.csproj | 2 +- .../Input/AuthoringInput.cs | 6 +- .../Clients/CloudCodeModuleClientTests.cs | 3 +- .../Clients/CloudCodeScriptClientTests.cs | 18 +- .../Handlers/CreateHandlerTests.cs | 12 +- .../Handlers/ExportModulesHandlerTests.cs | 15 +- .../Handlers/ExportScriptsHandlerTests.cs | 12 +- .../Handlers/GetHandlerTests.cs | 4 +- .../Handlers/GetModuleHandlerTests.cs | 4 +- .../Handlers/ImportModulesHandlerTests.cs | 26 +- .../Handlers/ImportScriptsHandlerTests.cs | 32 +- .../Mock/CloudCodeApiV1AsyncMock.cs | 6 +- .../Model/OutputScriptTests.cs | 8 +- .../Service/CloudCodeInputParserTests.cs | 32 +- .../Service/CloudCodeServiceTests.cs | 58 +- ...ity.Services.Cli.CloudCode.UnitTest.csproj | 2 +- .../Clients/CloudCodeModuleClient.cs | 3 +- .../Clients/CloudCodeScriptClient.cs | 7 +- .../Scripts/CloudCodeScriptsImporter.cs | 7 +- .../Model/GetModuleResponseOutput.cs | 2 +- .../Model/GetScriptResponseOutput.cs | 4 +- .../Service/CloudCodeInputParser.cs | 32 +- .../Service/CloudCodeService.cs | 19 +- .../Service/ICloudCodeInputParser.cs | 6 +- .../Service/ICloudCodeService.cs | 3 +- .../Unity.Services.Cli.CloudCode.csproj | 2 +- .../Model/SyncEntry.cs | 6 +- .../Service/SynchronizationService.cs | 6 +- .../Service/UploadContentClient.cs | 4 +- .../CloudSaveDeploymentServiceTests.cs | 160 +++ .../Authoring/CloudSaveFetchServiceTests.cs | 164 +++ .../CloudSaveModuleTests.cs | 120 +++ .../Core/CloudSaveDeployFetchTestBase.cs | 53 + .../Core/CloudSaveDeploymentHandlerTests.cs | 477 +++++++++ .../Core/CloudSaveFetchHandlerTests.cs | 421 ++++++++ .../Handlers/CreateCustomIndexHandlerTests.cs | 108 ++ .../Handlers/CreatePlayerIndexHandlerTests.cs | 111 ++ .../Handlers/ListCustomIdsHandlerTests.cs | 107 ++ .../Handlers/ListIndexesHandlerTests.cs | 97 ++ .../Handlers/ListPlayerIdsHandlerTests.cs | 106 ++ .../Handlers/QueryCustomDataHandlerTests.cs | 86 ++ .../Handlers/QueryPlayerDataHandlerTests.cs | 85 ++ .../Service/CloudSaveDataServiceTests.cs | 952 ++++++++++++++++++ ...ity.Services.Cli.CloudSave.UnitTest.csproj | 28 + .../Utils/TestValues.cs | 8 + .../CloudSaveModule.cs | 324 ++++++ .../Deploy/CloudSaveClient.cs | 171 ++++ .../Deploy/CloudSaveDeploymentService.cs | 91 ++ .../Deploy/SimpleResourceConfigFile.cs | 49 + .../Exceptions/CloudSaveException.cs | 25 + .../Fetch/CloudSaveFetchService.cs | 95 ++ .../Handlers/CreateCustomIndexHandler.cs | 48 + .../Handlers/CreatePlayerIndexHandler.cs | 48 + .../Handlers/ListCustomDataIdsHandler.cs | 36 + .../Handlers/ListIndexesHandler.cs | 34 + .../Handlers/ListPlayerDataIdsHandler.cs | 38 + .../Handlers/QueryCustomDataHandler.cs | 46 + .../Handlers/QueryPlayerDataHandler.cs | 46 + .../Handlers/RequestBodyHandler.cs | 25 + .../IO/CloudSaveSimpleResourceLoader.cs | 112 +++ .../IO/FileSystem.cs | 5 + .../Input/CreateIndexInput.cs | 28 + .../Input/ListDataIdsInput.cs | 15 + .../Input/QueryDataInput.cs | 24 + .../Models/CreateIndexOutput.cs | 26 + .../Models/ListIndexesOutput.cs | 59 ++ .../Service/CloudSaveDataService.cs | 212 ++++ .../Service/ICloudSaveDataService.cs | 14 + .../Unity.Services.Cli.CloudSave.csproj | 39 + .../Utils/CustomIndexVisibilityTypes.cs | 17 + .../Utils/PlayerIndexVisibilityTypes.cs | 18 + .../Input/ConfigurationInput.cs | 1 + .../Unity.Services.Cli.Common.csproj | 2 +- .../Services/GameServerHostingConfigLoader.cs | 2 +- ...nity.Services.Cli.GameServerHosting.csproj | 6 + .../ServiceMocks/AccessApiMock.cs | 8 +- .../CloudCode/CloudCodeFetchMock.cs | 12 +- .../ServiceMocks/CloudCode/CloudCodeV1Mock.cs | 5 +- ...Services.Cli.Integration.MockServer.csproj | 2 +- .../AccessTests/AccessTests.cs | 4 +- .../Deploy/CloudCode/CloudCodeDeployTests.cs | 2 +- .../CloudCodeTests/CloudCodeScriptTests.cs | 32 - .../CloudSaveTests/CloudSaveTests.cs | 577 +++++++++++ .../ConfigTests/ConfigTests.cs | 9 +- .../Unity.Services.Cli.IntegrationTest.csproj | 3 +- .../AdminApiClientUnitTests.cs | 319 ++++++ .../ConfigParserUnitTests.cs | 276 +++++ .../DeploymentServiceUnitTests.cs | 74 ++ .../FetchServiceUnitTests.cs | 62 ++ .../MatchmakerModuleTest.cs | 57 ++ .../MatchmakerServiceUnitTests.cs | 243 +++++ .../SampleConfigs/CoreSampleConfig.cs | 320 ++++++ .../SampleConfigs/GeneratedSampleConfig.cs | 297 ++++++ .../SampleConfigs/JsonSampleConfigLoader.cs | 39 + .../SampleConfigs/MultiplaySampleConfig.cs | 82 ++ .../SampleConfigs/TemplateQueueConfig.json | 70 ++ .../SampleConfigs/TestEmptyQueueConfig.json | 7 + .../SampleConfigs/TestEnvironmentConfig.json | 5 + .../SampleConfigs/TestQueueConfig.json | 289 ++++++ ...ty.Services.Cli.Matchmaker.UnitTest.csproj | 32 + .../AdminApiClient/MatchmakerAdminClient.cs | 121 +++ .../AdminApiClient/ModelCoreToGenerated.cs | 317 ++++++ .../AdminApiClient/ModelGeneratedToCore.cs | 298 ++++++ .../MatchmakerModule.cs | 56 ++ .../Parser/DataMemberEnumConverter.cs | 75 ++ .../Parser/HostingConfigTypeConverter.cs | 30 + .../Parser/JsonObjectSpecializedConverter.cs | 38 + .../Parser/MatchmakerConfigParser.cs | 200 ++++ .../Parser/ResourceNameConverter.cs | 22 + .../Service/AdminApiTargetEndpoint.cs | 13 + .../Service/IMatchmakerService.cs | 20 + .../Service/MatchmakerDeploymentService.cs | 153 +++ .../Service/MatchmakerException.cs | 25 + .../Service/MatchmakerFetchService.cs | 51 + .../Service/MatchmakerService.cs | 167 +++ .../Service/QueueConfigTemplate.cs | 87 ++ .../Unity.Services.Cli.Matchmaker.csproj | 29 + Unity.Services.Cli/Unity.Services.Cli.sln | 25 + .../Unity.Services.Cli.sln.DotSettings | 4 +- .../Unity.Services.Cli/Program.cs | 7 +- .../Unity.Services.Cli.csproj | 4 +- .../Batching/Batching.cs | 259 +++++ .../Deploy/CloudSaveDeploymentHandler.cs | 119 +++ .../Deploy/CloudSaveFetchDeployBase.cs | 86 ++ .../Deploy/DeployResult.cs | 10 + .../Deploy/ICloudSaveDeploymentHandler.cs | 17 + .../Fetch/CloudSaveFetchHandler.cs | 154 +++ .../Fetch/FetchResult.cs | 10 + .../Fetch/ICloudSaveFetchHandler.cs | 17 + .../IO/ICloudSaveSimpleResourceLoader.cs | 13 + .../IO/IFileSystem.cs | 19 + .../Model/ClientException.cs | 12 + .../Model/Constants.cs | 11 + .../Model/IResource.cs | 35 + .../Model/SimpleResource.cs | 23 + .../Model/SimpleResourceItem.cs | 94 ++ .../Model/Statuses.cs | 38 + .../Service/ICloudSaveClient.cs | 20 + ...y.Services.CloudSave.Authoring.Core.csproj | 25 + .../DuplicateResourceValidation.cs | 44 + 152 files changed, 10577 insertions(+), 271 deletions(-) create mode 100644 Samples/Deploy/Matchmaker/environment-config.mme create mode 100644 Samples/Deploy/Matchmaker/queue.mmq create mode 100644 Unity.Services.Cli/Unity.Services.Cli.CloudSave.UnitTest/Authoring/CloudSaveDeploymentServiceTests.cs create mode 100644 Unity.Services.Cli/Unity.Services.Cli.CloudSave.UnitTest/Authoring/CloudSaveFetchServiceTests.cs create mode 100644 Unity.Services.Cli/Unity.Services.Cli.CloudSave.UnitTest/CloudSaveModuleTests.cs create mode 100644 Unity.Services.Cli/Unity.Services.Cli.CloudSave.UnitTest/Core/CloudSaveDeployFetchTestBase.cs create mode 100644 Unity.Services.Cli/Unity.Services.Cli.CloudSave.UnitTest/Core/CloudSaveDeploymentHandlerTests.cs create mode 100644 Unity.Services.Cli/Unity.Services.Cli.CloudSave.UnitTest/Core/CloudSaveFetchHandlerTests.cs create mode 100644 Unity.Services.Cli/Unity.Services.Cli.CloudSave.UnitTest/Handlers/CreateCustomIndexHandlerTests.cs create mode 100644 Unity.Services.Cli/Unity.Services.Cli.CloudSave.UnitTest/Handlers/CreatePlayerIndexHandlerTests.cs create mode 100644 Unity.Services.Cli/Unity.Services.Cli.CloudSave.UnitTest/Handlers/ListCustomIdsHandlerTests.cs create mode 100644 Unity.Services.Cli/Unity.Services.Cli.CloudSave.UnitTest/Handlers/ListIndexesHandlerTests.cs create mode 100644 Unity.Services.Cli/Unity.Services.Cli.CloudSave.UnitTest/Handlers/ListPlayerIdsHandlerTests.cs create mode 100644 Unity.Services.Cli/Unity.Services.Cli.CloudSave.UnitTest/Handlers/QueryCustomDataHandlerTests.cs create mode 100644 Unity.Services.Cli/Unity.Services.Cli.CloudSave.UnitTest/Handlers/QueryPlayerDataHandlerTests.cs create mode 100644 Unity.Services.Cli/Unity.Services.Cli.CloudSave.UnitTest/Service/CloudSaveDataServiceTests.cs create mode 100644 Unity.Services.Cli/Unity.Services.Cli.CloudSave.UnitTest/Unity.Services.Cli.CloudSave.UnitTest.csproj create mode 100644 Unity.Services.Cli/Unity.Services.Cli.CloudSave.UnitTest/Utils/TestValues.cs create mode 100644 Unity.Services.Cli/Unity.Services.Cli.CloudSave/CloudSaveModule.cs create mode 100644 Unity.Services.Cli/Unity.Services.Cli.CloudSave/Deploy/CloudSaveClient.cs create mode 100644 Unity.Services.Cli/Unity.Services.Cli.CloudSave/Deploy/CloudSaveDeploymentService.cs create mode 100644 Unity.Services.Cli/Unity.Services.Cli.CloudSave/Deploy/SimpleResourceConfigFile.cs create mode 100644 Unity.Services.Cli/Unity.Services.Cli.CloudSave/Exceptions/CloudSaveException.cs create mode 100644 Unity.Services.Cli/Unity.Services.Cli.CloudSave/Fetch/CloudSaveFetchService.cs create mode 100644 Unity.Services.Cli/Unity.Services.Cli.CloudSave/Handlers/CreateCustomIndexHandler.cs create mode 100644 Unity.Services.Cli/Unity.Services.Cli.CloudSave/Handlers/CreatePlayerIndexHandler.cs create mode 100644 Unity.Services.Cli/Unity.Services.Cli.CloudSave/Handlers/ListCustomDataIdsHandler.cs create mode 100644 Unity.Services.Cli/Unity.Services.Cli.CloudSave/Handlers/ListIndexesHandler.cs create mode 100644 Unity.Services.Cli/Unity.Services.Cli.CloudSave/Handlers/ListPlayerDataIdsHandler.cs create mode 100644 Unity.Services.Cli/Unity.Services.Cli.CloudSave/Handlers/QueryCustomDataHandler.cs create mode 100644 Unity.Services.Cli/Unity.Services.Cli.CloudSave/Handlers/QueryPlayerDataHandler.cs create mode 100644 Unity.Services.Cli/Unity.Services.Cli.CloudSave/Handlers/RequestBodyHandler.cs create mode 100644 Unity.Services.Cli/Unity.Services.Cli.CloudSave/IO/CloudSaveSimpleResourceLoader.cs create mode 100644 Unity.Services.Cli/Unity.Services.Cli.CloudSave/IO/FileSystem.cs create mode 100644 Unity.Services.Cli/Unity.Services.Cli.CloudSave/Input/CreateIndexInput.cs create mode 100644 Unity.Services.Cli/Unity.Services.Cli.CloudSave/Input/ListDataIdsInput.cs create mode 100644 Unity.Services.Cli/Unity.Services.Cli.CloudSave/Input/QueryDataInput.cs create mode 100644 Unity.Services.Cli/Unity.Services.Cli.CloudSave/Models/CreateIndexOutput.cs create mode 100644 Unity.Services.Cli/Unity.Services.Cli.CloudSave/Models/ListIndexesOutput.cs create mode 100644 Unity.Services.Cli/Unity.Services.Cli.CloudSave/Service/CloudSaveDataService.cs create mode 100644 Unity.Services.Cli/Unity.Services.Cli.CloudSave/Service/ICloudSaveDataService.cs create mode 100644 Unity.Services.Cli/Unity.Services.Cli.CloudSave/Unity.Services.Cli.CloudSave.csproj create mode 100644 Unity.Services.Cli/Unity.Services.Cli.CloudSave/Utils/CustomIndexVisibilityTypes.cs create mode 100644 Unity.Services.Cli/Unity.Services.Cli.CloudSave/Utils/PlayerIndexVisibilityTypes.cs create mode 100644 Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/CloudSaveTests/CloudSaveTests.cs create mode 100644 Unity.Services.Cli/Unity.Services.Cli.Matchmaker.UnitTest/AdminApiClientUnitTests.cs create mode 100644 Unity.Services.Cli/Unity.Services.Cli.Matchmaker.UnitTest/ConfigParserUnitTests.cs create mode 100644 Unity.Services.Cli/Unity.Services.Cli.Matchmaker.UnitTest/DeploymentServiceUnitTests.cs create mode 100644 Unity.Services.Cli/Unity.Services.Cli.Matchmaker.UnitTest/FetchServiceUnitTests.cs create mode 100644 Unity.Services.Cli/Unity.Services.Cli.Matchmaker.UnitTest/MatchmakerModuleTest.cs create mode 100644 Unity.Services.Cli/Unity.Services.Cli.Matchmaker.UnitTest/MatchmakerServiceUnitTests.cs create mode 100644 Unity.Services.Cli/Unity.Services.Cli.Matchmaker.UnitTest/SampleConfigs/CoreSampleConfig.cs create mode 100644 Unity.Services.Cli/Unity.Services.Cli.Matchmaker.UnitTest/SampleConfigs/GeneratedSampleConfig.cs create mode 100644 Unity.Services.Cli/Unity.Services.Cli.Matchmaker.UnitTest/SampleConfigs/JsonSampleConfigLoader.cs create mode 100644 Unity.Services.Cli/Unity.Services.Cli.Matchmaker.UnitTest/SampleConfigs/MultiplaySampleConfig.cs create mode 100644 Unity.Services.Cli/Unity.Services.Cli.Matchmaker.UnitTest/SampleConfigs/TemplateQueueConfig.json create mode 100644 Unity.Services.Cli/Unity.Services.Cli.Matchmaker.UnitTest/SampleConfigs/TestEmptyQueueConfig.json create mode 100644 Unity.Services.Cli/Unity.Services.Cli.Matchmaker.UnitTest/SampleConfigs/TestEnvironmentConfig.json create mode 100644 Unity.Services.Cli/Unity.Services.Cli.Matchmaker.UnitTest/SampleConfigs/TestQueueConfig.json create mode 100644 Unity.Services.Cli/Unity.Services.Cli.Matchmaker.UnitTest/Unity.Services.Cli.Matchmaker.UnitTest.csproj create mode 100644 Unity.Services.Cli/Unity.Services.Cli.Matchmaker/AdminApiClient/MatchmakerAdminClient.cs create mode 100644 Unity.Services.Cli/Unity.Services.Cli.Matchmaker/AdminApiClient/ModelCoreToGenerated.cs create mode 100644 Unity.Services.Cli/Unity.Services.Cli.Matchmaker/AdminApiClient/ModelGeneratedToCore.cs create mode 100644 Unity.Services.Cli/Unity.Services.Cli.Matchmaker/MatchmakerModule.cs create mode 100644 Unity.Services.Cli/Unity.Services.Cli.Matchmaker/Parser/DataMemberEnumConverter.cs create mode 100644 Unity.Services.Cli/Unity.Services.Cli.Matchmaker/Parser/HostingConfigTypeConverter.cs create mode 100644 Unity.Services.Cli/Unity.Services.Cli.Matchmaker/Parser/JsonObjectSpecializedConverter.cs create mode 100644 Unity.Services.Cli/Unity.Services.Cli.Matchmaker/Parser/MatchmakerConfigParser.cs create mode 100644 Unity.Services.Cli/Unity.Services.Cli.Matchmaker/Parser/ResourceNameConverter.cs create mode 100644 Unity.Services.Cli/Unity.Services.Cli.Matchmaker/Service/AdminApiTargetEndpoint.cs create mode 100644 Unity.Services.Cli/Unity.Services.Cli.Matchmaker/Service/IMatchmakerService.cs create mode 100644 Unity.Services.Cli/Unity.Services.Cli.Matchmaker/Service/MatchmakerDeploymentService.cs create mode 100644 Unity.Services.Cli/Unity.Services.Cli.Matchmaker/Service/MatchmakerException.cs create mode 100644 Unity.Services.Cli/Unity.Services.Cli.Matchmaker/Service/MatchmakerFetchService.cs create mode 100644 Unity.Services.Cli/Unity.Services.Cli.Matchmaker/Service/MatchmakerService.cs create mode 100644 Unity.Services.Cli/Unity.Services.Cli.Matchmaker/Service/QueueConfigTemplate.cs create mode 100644 Unity.Services.Cli/Unity.Services.Cli.Matchmaker/Unity.Services.Cli.Matchmaker.csproj create mode 100644 Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/Batching/Batching.cs create mode 100644 Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/Deploy/CloudSaveDeploymentHandler.cs create mode 100644 Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/Deploy/CloudSaveFetchDeployBase.cs create mode 100644 Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/Deploy/DeployResult.cs create mode 100644 Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/Deploy/ICloudSaveDeploymentHandler.cs create mode 100644 Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/Fetch/CloudSaveFetchHandler.cs create mode 100644 Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/Fetch/FetchResult.cs create mode 100644 Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/Fetch/ICloudSaveFetchHandler.cs create mode 100644 Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/IO/ICloudSaveSimpleResourceLoader.cs create mode 100644 Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/IO/IFileSystem.cs create mode 100644 Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/Model/ClientException.cs create mode 100644 Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/Model/Constants.cs create mode 100644 Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/Model/IResource.cs create mode 100644 Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/Model/SimpleResource.cs create mode 100644 Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/Model/SimpleResourceItem.cs create mode 100644 Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/Model/Statuses.cs create mode 100644 Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/Service/ICloudSaveClient.cs create mode 100644 Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/Unity.Services.CloudSave.Authoring.Core.csproj create mode 100644 Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/Validations/DuplicateResourceValidation.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ea7259..59e2d6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,13 +5,23 @@ 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.5.0] - 2024-06-12 + +### Fixed +- Now supporting multiple entries on `--services` and `--key` options. +- Fix Cloud Content Delivery issue of content upload failure or timeouts for large files. + +### Added +- Added Cloud Save module service commands. Run `ugs cloud-save -h` to show usage. +- Added support for [Matchmaker](https://docs.unity.com/ugs/en-us/manual/matchmaker/manual/matchmaker-overview) to Deploy and Fetch. + ## [1.4.0] - 2024-04-22 ### Fixed - Improve Cloud Code script in-script parameter wrong argument type parsing error - Cloud Content Delivery Module Service commands. Run `ugs ccd -h` to show usage. ### Changed -- The env list command now outputs as a table +- The `env list` command now outputs as a table ## [1.3.0] - 2024-02-29 ### Added diff --git a/Samples/Deploy/Leaderboards/lbsample.lb b/Samples/Deploy/Leaderboards/lbsample.lb index 6b83dc3..4f6a579 100644 --- a/Samples/Deploy/Leaderboards/lbsample.lb +++ b/Samples/Deploy/Leaderboards/lbsample.lb @@ -4,7 +4,7 @@ "UpdateType": "keepBest", "Name": "My Leaderboard", "ResetConfig": { - "Start": "2023-08-25T00:00:00-04:00", + "Start": "2033-08-25T00:00:00-04:00", "Schedule": "0 12 1 * *" }, "TieringConfig": { diff --git a/Samples/Deploy/Matchmaker/environment-config.mme b/Samples/Deploy/Matchmaker/environment-config.mme new file mode 100644 index 0000000..bf42422 --- /dev/null +++ b/Samples/Deploy/Matchmaker/environment-config.mme @@ -0,0 +1,5 @@ +{ + "$schema": "https://ugs-config-schemas.unity3d.com/v1/matchmaker/matchmaker-environment-config.schema.json", + "enabled": true, + "defaultQueueName": "default-queue" +} diff --git a/Samples/Deploy/Matchmaker/queue.mmq b/Samples/Deploy/Matchmaker/queue.mmq new file mode 100644 index 0000000..d080c7f --- /dev/null +++ b/Samples/Deploy/Matchmaker/queue.mmq @@ -0,0 +1,67 @@ +{ + "$schema": "https://ugs-config-schemas.unity3d.com/v1/matchmaker/matchmaker-queue.schema.json", + "name": "default-queue", + "enabled": true, + "maxPlayersPerTicket": 2, + "defaultPool": { + "variants": [], + "name": "default-pool", + "enabled": true, + "timeoutSeconds": 90, + "matchLogic": { + "matchDefinition": { + "teams": [ + { + "name": "Team", + "teamCount": { + "min": 2, + "max": 2, + "relaxations": [] + }, + "playerCount": { + "min": 1, + "max": 2, + "relaxations": [] + }, + "teamRules": [] + } + ], + "matchRules": [ + { + "source": "Players.ExternalData.CloudSave.Skill", + "name": "skill-diff", + "type": "Difference", + "reference": 500, + "overlap": 0.0, + "enableRule": false, + "not": false, + "relaxations": [] + }, + { + "source": "Players.QosResults.Latency", + "name": "QoS", + "type": "LessThanEqual", + "reference": 100, + "overlap": 0.0, + "enableRule": false, + "not": false, + "relaxations": [ + { + "type": "ReferenceControl.Replace", + "ageType": "Oldest", + "atSeconds": 30.0, + "value": 200 + } + ] + } + ] + }, + "name": "Default Pool Rules", + "backfillEnabled": false + }, + "matchHosting": { + "type": "MatchId", + } + }, + "filteredPools": [] +} diff --git a/Samples/Deploy/instructions.md b/Samples/Deploy/instructions.md index aa3cbaa..099fcf4 100644 --- a/Samples/Deploy/instructions.md +++ b/Samples/Deploy/instructions.md @@ -11,6 +11,7 @@ [Deploy Economy](#deploy-economy)
[Deploy Leaderboards](#deploy-leaderboards)
[Deploy Access](#deploy-access)
+[Deploy Matchmaker](#deploy-matchmaker)
## Deploy Cloud Code Script @@ -176,6 +177,29 @@ To create a deployable access file, you need a `.ac` file with the following pat } ``` Please take [sample-policy.ac] as an example. For more details, please check [Access Control Documentation Portal] and/or [Access Control schema]. + +## Deploy Matchmaker + +Run command from [Samples/Deploy] directory: +``` +ugs deploy ./Matchmaker +``` + +You will find the resource from [environment-config.mme] and [queue.mmq] published in your dashboard +for the configured project and environment. + +### Create Matchmaker Files + +To create a deployable access file, +you need `.mme` and `.mmq` files, each containing a json with the required information for the specific resource. + +File Schemas: +- [Matchmaker Environment Config resource schema] +- [Matchmaker Queue resource schema] + +Please take [environment-config.mme] and [queue.mmq] as examples. +For more details, please check the [Matchmaker Admin API]. + ## Deploy all Samples Run command from [Samples/Deploy] directory: ``` @@ -217,6 +241,9 @@ You will find all the contents deployed in your dashboard for the configured pro [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 +[Matchmaker Admin API]: https://services.docs.unity.com/matchmaker-admin/ +[Matchmaker Environment Config resource schema]: https://ugs-config-schemas.unity3d.com/v1/matchmaker/matchmaker-environment-config.schema.json +[Matchmaker Queue resource schema]: https://ugs-config-schemas.unity3d.com/v1/matchmaker/matchmaker-queue.schema.json [Declare parameters in the script]: https://docs.unity.com/cloud-code/authoring-scripts-editor.html#Declare_parameters_in_the_script [Module.ccm]: /Samples/Deploy/CloudCode/Module/Module.ccm [Script.js]: /Samples/Deploy/CloudCode/Script/Script.js @@ -237,3 +264,5 @@ You will find all the contents deployed in your dashboard for the configured pro [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 +[environment-config.mme]: /Samples/Deploy/Matchmaker/environment-config.mme +[queue.mmq]: /Samples/Deploy/Matchmaker/queue.mmq diff --git a/Third Party Notices.md b/Third Party Notices.md index 7a53c2e..4e29f45 100644 --- a/Third Party Notices.md +++ b/Third Party Notices.md @@ -596,3 +596,40 @@ Permission is hereby granted, free of charge, to any person obtaining a copy of The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +
+ +Component Name: Compare-Net-Objects + +Licence Type: Microsoft Public License (Ms-PL) + +Copyright (c) 2021 Greg Finzer + +https://github.com/GregFinzer/Compare-Net-Objects + +This license governs use of the accompanying software. If you use the software, you accept this license. If you do not accept the license, do not use the software. + +Definitions +The terms "reproduce," "reproduction," "derivative works," and "distribution" have the same meaning here as under U.S. copyright law. + +A "contribution" is the original software, or any additions or changes to the software. + +A "contributor" is any person that distributes its contribution under this license. + +"Licensed patents" are a contributor's patent claims that read directly on its contribution. + +Grant of Rights +(A) Copyright Grant- Subject to the terms of this license, including the license conditions and limitations in section 3, each contributor grants you a non-exclusive, worldwide, royalty-free copyright license to reproduce its contribution, prepare derivative works of its contribution, and distribute its contribution or any derivative works that you create. + +(B) Patent Grant- Subject to the terms of this license, including the license conditions and limitations in section 3, each contributor grants you a non-exclusive, worldwide, royalty-free license under its licensed patents to make, have made, use, sell, offer for sale, import, and/or otherwise dispose of its contribution in the software or derivative works of the contribution in the software. + +Conditions and Limitations +(A) No Trademark License- This license does not grant you rights to use any contributors' name, logo, or trademarks. + +(B) If you bring a patent claim against any contributor over patents that you claim are infringed by the software, your patent license from such contributor to the software ends automatically. + +(C) If you distribute any portion of the software, you must retain all copyright, patent, trademark, and attribution notices that are present in the software. + +(D) If you distribute any portion of the software in source code form, you may do so only under this license by including a complete copy of this license with your distribution. If you distribute any portion of the software in compiled or object code form, you may only do so under a license that complies with this license. + +(E) The software is licensed "as-is." You bear the risk of using it. The contributors give no express warranties, guarantees or conditions. You may have additional consumer rights under your local laws which this license cannot change. To the extent permitted under your local laws, the contributors exclude the implied warranties of merchantability, fitness for a particular purpose and non-infringement. 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 index 4cb8b4d..97e3289 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.Access.UnitTest/Deploy/ProjectAccessClientTests.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.Access.UnitTest/Deploy/ProjectAccessClientTests.cs @@ -68,7 +68,7 @@ public async Task GetAsyncForPolicyWithNoStatements() [Test] public async Task GetAsyncForPolicyWithStatements() { - var policy = TestMocks.GetPolicy(new List(){TestMocks.GetStatement()}); + var policy = TestMocks.GetPolicy(new List(){TestMocks.GetProjectStatement()}); m_MockAccessService.Setup(r => r.GetPolicyAsync(k_TestProjectId, k_TestEnvironmentId, CancellationToken.None)).ReturnsAsync(policy); var authoringStatements = new List() @@ -86,7 +86,7 @@ public async Task GetAsyncForPolicyWithStatements() 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")}); + var policy = TestMocks.GetPolicy(new List(){TestMocks.GetProjectStatement("sid-1"), TestMocks.GetProjectStatement("sid-2")}); await m_ProjectAccessClient!.UpsertAsync(authoringStatements); m_MockAccessService.Verify(ac => ac.UpsertProjectAccessCaCAsync(k_TestProjectId, k_TestEnvironmentId, policy, CancellationToken.None), Times.Once); } 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 f82e6d9..f20d78a 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 @@ -206,7 +206,7 @@ public void UpsertPolicyAsync_InvalidInput() [Test] public async Task UpsertPlayerPolicyAsync_Valid() { - m_PlayerPolicyApi.Setup(a => a.UpsertPlayerPolicyAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + m_PlayerPolicyApi.Setup(a => a.UpsertPlayerPolicyAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), CancellationToken.None)); await m_AccessService!.UpsertPlayerPolicyAsync( @@ -221,7 +221,7 @@ public async Task UpsertPlayerPolicyAsync_Valid() It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); @@ -231,7 +231,7 @@ public async Task UpsertPlayerPolicyAsync_Valid() public void UpsertPlayerPolicyAsync_Invalid_ApiThrowsError() { m_PlayerPolicyApi.Setup(a => a.UpsertPlayerPolicyAsync(It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny(), It.IsAny(), CancellationToken.None)).Throws(); + It.IsAny(), It.IsAny(), It.IsAny(), CancellationToken.None)).Throws(); Assert.ThrowsAsync( () => m_AccessService!.UpsertPlayerPolicyAsync(TestValues.ValidProjectId, TestValues.ValidEnvironmentId, TestValues.ValidPlayerId, @@ -343,9 +343,9 @@ public void DeletePlayerPolicyStatementsAsync_InvalidInput() [Test] public async Task UpsertProjectAccessCaCAsync_Valid() { - var statements = new List() + var statements = new List() { - TestMocks.GetStatement() + TestMocks.GetProjectStatement() }; m_ProjectPolicyApi.Setup(a => a.UpsertPolicyAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), CancellationToken.None)); diff --git a/Unity.Services.Cli/Unity.Services.Cli.Access.UnitTest/Unity.Services.Cli.Access.UnitTest.csproj b/Unity.Services.Cli/Unity.Services.Cli.Access.UnitTest/Unity.Services.Cli.Access.UnitTest.csproj index a2f6e79..9bf3048 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.Access.UnitTest/Unity.Services.Cli.Access.UnitTest.csproj +++ b/Unity.Services.Cli/Unity.Services.Cli.Access.UnitTest/Unity.Services.Cli.Access.UnitTest.csproj @@ -15,7 +15,7 @@ - + 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 7f65740..17249a0 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 @@ -5,17 +5,27 @@ namespace Unity.Services.Cli.Access.UnitTest.Utils; class TestMocks { - public static Statement GetStatement(string sid = "statement-1") + public static ProjectStatement GetProjectStatement(string sid = "statement-1") { List action = new List(); action.Add("*"); - Statement statement = new Statement(sid: sid, action: action, effect: "Deny", principal: "Player", + ProjectStatement statement = new ProjectStatement(sid: sid, action: action, effect: "Deny", principal: "Player", resource: "urn:ugs:*"); return statement; } - public static Policy GetPolicy(List statements) + public static PlayerStatement GetPlayerStatement(string sid = "statement-1") + { + List action = new List(); + action.Add("*"); + + PlayerStatement statement = new PlayerStatement(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; @@ -47,8 +57,8 @@ public static AccessControlStatement GetAuthoringStatement( public static PlayerPolicy GetPlayerPolicy() { - List statements = new List(); - statements.Add(GetStatement()); + List statements = new List(); + statements.Add(GetPlayerStatement()); PlayerPolicy playerPolicy = new PlayerPolicy(playerId: TestValues.ValidPlayerId, statements); return playerPolicy; diff --git a/Unity.Services.Cli/Unity.Services.Cli.Access/Deploy/ProjectAccessClient.cs b/Unity.Services.Cli/Unity.Services.Cli.Access/Deploy/ProjectAccessClient.cs index 0d4b440..0b704ed 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.Access/Deploy/ProjectAccessClient.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.Access/Deploy/ProjectAccessClient.cs @@ -90,7 +90,7 @@ static List GetAuthoringStatementsFromPolicy(Policy poli static Policy GetPolicyFromAuthoringStatements(IReadOnlyList authoringStatements) { var statements = authoringStatements.Select( - s => new Statement( + s => new ProjectStatement( sid: s.Sid, action: s.Action, effect: s.Effect, 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 ea40b8f..69a043b 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.Access/Service/AccessService.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.Access/Service/AccessService.cs @@ -147,10 +147,10 @@ public async Task UpsertPlayerPolicyAsync(string projectId, string environmentId var jsonString = ReadFile(file); - Policy? policy; + PlayerPolicyUpsert? policy; try { - policy = JsonConvert.DeserializeObject(jsonString, new JsonSerializerSettings + policy = JsonConvert.DeserializeObject(jsonString, new JsonSerializerSettings { MissingMemberHandling = MissingMemberHandling.Error, }); 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 fbce1b6..d9cc786 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 @@ -20,7 +20,7 @@ - + diff --git a/Unity.Services.Cli/Unity.Services.Cli.Authoring/Input/AuthoringInput.cs b/Unity.Services.Cli/Unity.Services.Cli.Authoring/Input/AuthoringInput.cs index 2506fd1..d964c85 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.Authoring/Input/AuthoringInput.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.Authoring/Input/AuthoringInput.cs @@ -31,7 +31,11 @@ public class AuthoringInput : CommonInput "--services", "-s" }, - "The name(s) of the service(s) to perform the command on."); + "The name(s) of the service(s) to perform the command on.") + { + Arity = ArgumentArity.OneOrMore, + AllowMultipleArgumentsPerToken = true + }; [InputBinding(nameof(ServiceOptions))] public IReadOnlyList Services { get; set; } = new List(); diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Authoring/Clients/CloudCodeModuleClientTests.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Authoring/Clients/CloudCodeModuleClientTests.cs index 72ca227..ef867d3 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Authoring/Clients/CloudCodeModuleClientTests.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Authoring/Clients/CloudCodeModuleClientTests.cs @@ -10,7 +10,6 @@ using Unity.Services.CloudCode.Authoring.Editor.Core.Model; using Unity.Services.Gateway.CloudCodeApiV1.Generated.Client; using Unity.Services.Gateway.CloudCodeApiV1.Generated.Model; -using APILanguage = Unity.Services.Gateway.CloudCodeApiV1.Generated.Model.Language; using CoreLanguage = Unity.Services.CloudCode.Authoring.Editor.Core.Model.Language; namespace Unity.Services.Cli.CloudCode.UnitTest.Authoring; @@ -100,7 +99,7 @@ public async Task GetModuleSucceed() { var apiResponse = new GetModuleResponse( name: k_ModuleName, - language: APILanguage.JS); + language: "JS"); m_MockService.Setup(c => c.GetModuleAsync(TestValues.ValidProjectId, TestValues.ValidEnvironmentId, k_ModuleName, CancellationToken.None)) .ReturnsAsync(apiResponse); var moduleName = new ScriptName(k_ModuleName); diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Authoring/Clients/CloudCodeScriptClientTests.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Authoring/Clients/CloudCodeScriptClientTests.cs index c54fb58..be081c1 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Authoring/Clients/CloudCodeScriptClientTests.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Authoring/Clients/CloudCodeScriptClientTests.cs @@ -16,7 +16,6 @@ using Unity.Services.CloudCode.Authoring.Editor.Core.Model; using Unity.Services.Gateway.CloudCodeApiV1.Generated.Client; using Unity.Services.Gateway.CloudCodeApiV1.Generated.Model; -using Language = Unity.Services.Gateway.CloudCodeApiV1.Generated.Model.Language; namespace Unity.Services.Cli.CloudCode.UnitTest.Authoring; @@ -93,8 +92,8 @@ public async Task UploadFromFileCreateSucceed() TestValues.ValidProjectId, TestValues.ValidEnvironmentId, k_ScriptName, - ScriptType.API, - Language.JS, + "API", + "JS", k_Script, It.IsAny>(), CancellationToken.None), @@ -121,8 +120,8 @@ public async Task UploadFromFileUpdateSucceed() It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), - It.IsAny(), + It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny()), @@ -159,8 +158,8 @@ public void UploadFromFileUpdateThrowException() It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), - It.IsAny(), + It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny()), @@ -174,8 +173,8 @@ public async Task ListScriptsSucceed() { new( k_ScriptName, - ScriptType.API, - Language.JS, + "API", + "JS", lastPublishedDate: DateTime.Now, published: false, lastPublishedVersion: 0) @@ -378,6 +377,7 @@ public async Task GetSucceed() { var expectedResponse = new GetScriptResponse( name: k_ScriptName, + type: "API", activeScript: new GetScriptResponseActiveScript( k_Script, _params: new List { diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Handlers/CreateHandlerTests.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Handlers/CreateHandlerTests.cs index 25c8e02..d02d813 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Handlers/CreateHandlerTests.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Handlers/CreateHandlerTests.cs @@ -22,8 +22,8 @@ namespace Unity.Services.Cli.CloudCode.UnitTest.Handlers; [TestFixture] class CreateHandlerTests { - static readonly string k_ValidScriptType = ScriptType.API.ToString(); - static readonly string k_ValidScriptLanguage = Language.JS.ToString(); + static readonly string k_ValidScriptType = "API"; + static readonly string k_ValidScriptLanguage = "JS"; static readonly List k_Parameters = new(); readonly Mock m_MockUnityEnvironment = new(); readonly Mock m_MockCloudCode = new(); @@ -67,9 +67,9 @@ public async Task CreateAsync_CallsCreateAsyncWhenInputIsValid() m_MockUnityEnvironment.Setup(x => x.FetchIdentifierAsync(CancellationToken.None)) .ReturnsAsync(TestValues.ValidEnvironmentId); m_MockInputParseService.Setup(x => x.ParseScriptType(input)) - .Returns(ScriptType.API); + .Returns("API"); m_MockInputParseService.Setup(x => x.ParseLanguage(input)) - .Returns(Language.JS); + .Returns("JS"); m_MockInputParseService.Setup(x => x.LoadScriptCodeAsync(input, CancellationToken.None)) .ReturnsAsync(TestValues.ValidCode); m_MockInputParseService.SetupGet(x => x.CloudCodeScriptParser) @@ -94,8 +94,8 @@ await CreateHandler.CreateAsync( TestValues.ValidProjectId, TestValues.ValidEnvironmentId, TestValues.ValidScriptName, - ScriptType.API, - Language.JS, + "API", + "JS", TestValues.ValidCode, k_Parameters, 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 856e4ff..487263e 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 @@ -21,7 +21,6 @@ using Unity.Services.CloudCode.Authoring.Editor.Core.Model; using Unity.Services.Gateway.CloudCodeApiV1.Generated.Model; using AuthoringLanguage = Unity.Services.CloudCode.Authoring.Editor.Core.Model.Language; -using Language = Unity.Services.Gateway.CloudCodeApiV1.Generated.Model.Language; using Module = Unity.Services.Cli.CloudCode.Deploy.CloudCodeModule; namespace Unity.Services.Cli.CloudCode.UnitTest.Handlers; @@ -36,19 +35,19 @@ class ExportModulesHandlerTests readonly Mock m_MockLogger = new(); readonly Mock m_MockArchiver = new(); readonly Mock m_MockLoadingIndicator = new(); - readonly static DateTime DateNow = DateTime.Now; + readonly static DateTime k_DateNow = DateTime.Now; readonly IEnumerable m_ModulesListResponse = new List() { - new("test1", Language.JS, new Dictionary(), "url", DateNow), - new("test2", Language.JS, new Dictionary(), "url", DateNow), - new("test3", Language.JS, new Dictionary(),"url", DateNow), + new("test1", "JS", new Dictionary(), "url", k_DateNow), + new("test2", "JS", new Dictionary(), "url", k_DateNow), + new("test3", "JS", new Dictionary(),"url", k_DateNow), }; readonly IEnumerable m_Modules = new List() { - new(new ScriptName("test1"), AuthoringLanguage.JS, "test1","{}", new List(), DateNow.ToString()), - new(new ScriptName("test2"), AuthoringLanguage.JS, "test2","{}", new List(), DateNow.ToString()), - new(new ScriptName("test3"), AuthoringLanguage.JS, "test3","{}", new List(), DateNow.ToString()), + new(new ScriptName("test1"), AuthoringLanguage.JS, "test1","{}", new List(), k_DateNow.ToString()), + new(new ScriptName("test2"), AuthoringLanguage.JS, "test2","{}", new List(), k_DateNow.ToString()), + new(new ScriptName("test3"), AuthoringLanguage.JS, "test3","{}", new List(), k_DateNow.ToString()), }; CloudCodeModulesExporter? m_CloudCodeModulesExporter; 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 2f203d9..3c24fa3 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 @@ -40,10 +40,10 @@ class ExportScriptsHandlerTests readonly static DateTime DateNow = DateTime.Now; readonly IEnumerable m_ScriptsListResponse = new List() { - new("test1", ScriptType.API, Gateway.CloudCodeApiV1.Generated.Model.Language.JS, true, DateNow, 1), - new("test2", ScriptType.API, Gateway.CloudCodeApiV1.Generated.Model.Language.JS, true, DateNow, 1), - new("test3", ScriptType.API, Gateway.CloudCodeApiV1.Generated.Model.Language.JS, true, DateNow, 1), - new("test4", ScriptType.API, Gateway.CloudCodeApiV1.Generated.Model.Language.JS, true, DateNow, 1), + new("test1", "API", "JS", true, DateNow, 1), + new("test2", "API", "JS", true, DateNow, 1), + new("test3", "API", "JS", true, DateNow, 1), + new("test4", "API", "JS", true, DateNow, 1), }; readonly IEnumerable m_Scripts = new List() { @@ -128,8 +128,8 @@ public async Task ExportAsync_ExportsAndZips() cs => cs.GetAsync(It.IsAny(), It.IsAny(), script.Name.ToString(), It.IsAny())) .Returns(Task.FromResult( new GetScriptResponse(script.Name.ToString(), - ScriptType.API, - Gateway.CloudCodeApiV1.Generated.Model.Language.JS, + "API", + "JS", activeScript: new GetScriptResponseActiveScript( script.Body, 1, diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Handlers/GetHandlerTests.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Handlers/GetHandlerTests.cs index b3d4ba6..7f9991c 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Handlers/GetHandlerTests.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Handlers/GetHandlerTests.cs @@ -37,8 +37,8 @@ public void SetUp() .ReturnsAsync( new GetScriptResponse( "foo", - ScriptType.API, - Language.JS, + "API", + "JS", new GetScriptResponseActiveScript("bar", 1, DateTime.Now, new List()), _params: new List(), versions: new List diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Handlers/GetModuleHandlerTests.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Handlers/GetModuleHandlerTests.cs index e4fc0b6..b7b9ee4 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Handlers/GetModuleHandlerTests.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Handlers/GetModuleHandlerTests.cs @@ -38,7 +38,7 @@ public void SetUp() .ReturnsAsync( new GetModuleResponse( "foo", - Language.CS, + "CS", null, "url", dateTime, @@ -86,7 +86,7 @@ await GetModuleHandler.GetModuleAsync( var output = new GetModuleResponseOutput(new GetModuleResponse( cloudCodeInput.ModuleName, - Language.CS, + "CS", null, "url", dateTime, 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 8335d02..83b501f 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 @@ -16,13 +16,11 @@ using Unity.Services.Cli.CloudCode.UnitTest.Utils; using Unity.Services.Cli.CloudCode.Utils; using Unity.Services.Cli.Common.Console; -using Unity.Services.Cli.Common.Exceptions; using Unity.Services.Cli.Common.Utils; using Unity.Services.CloudCode.Authoring.Editor.Core.Model; using Unity.Services.Gateway.CloudCodeApiV1.Generated.Client; using Unity.Services.Gateway.CloudCodeApiV1.Generated.Model; using AuthoringLanguage = Unity.Services.CloudCode.Authoring.Editor.Core.Model.Language; -using Language = Unity.Services.Gateway.CloudCodeApiV1.Generated.Model.Language; using Module = Unity.Services.Cli.CloudCode.Deploy.CloudCodeModule; namespace Unity.Services.Cli.CloudCode.UnitTest.Handlers; @@ -48,32 +46,32 @@ const string k_ImportTestFileDirectory readonly Mock m_MockArchiver = new(); readonly Mock m_MockLoadingIndicator = new(); - readonly static DateTime DateNow = DateTime.Now; + readonly static DateTime k_DateNow = DateTime.Now; readonly IEnumerable m_ModulesListSingleModuleResponse = new List() { - new("test1", Language.JS, new Dictionary(), "url", DateNow, DateNow), + new("test1", "JS", new Dictionary(), "url", k_DateNow, k_DateNow), }; readonly Module m_MockNonDuplicateModule = new(new ScriptName("test"), AuthoringLanguage.JS, "test_3.ccm", "{}", - new List(), DateNow.ToString()); + new List(), k_DateNow.ToString()); // This mock script updates the existing script in m_MockModules if used within tests readonly Module m_MockModule = new(new ScriptName("test1"), AuthoringLanguage.JS, "test_3.ccm", "{}", - new List(), DateNow.ToString()); + new List(), k_DateNow.ToString()); readonly List m_MockModules = new() { new(new ScriptName("test1"), AuthoringLanguage.JS, "path", "{}", new List(), - DateNow.ToString()), + k_DateNow.ToString()), new(new ScriptName("test2"), AuthoringLanguage.JS, "path", "{}", new List(), - DateNow.ToString()), + k_DateNow.ToString()), new(new ScriptName("test3"), AuthoringLanguage.JS, "path", "{}", new List(), - DateNow.ToString()), + k_DateNow.ToString()), new(new ScriptName("test4"), AuthoringLanguage.JS, "path", "{}", new List(), - DateNow.ToString()), + k_DateNow.ToString()), }; CloudCodeModulesImporter? m_CloudCodeModulesImporter; @@ -190,7 +188,7 @@ public void ThrowsWhenModulePathIsEmpty() .ReturnsAsync(new List() { new(new ScriptName("test1"), AuthoringLanguage.JS, "", "{}", - new List(), DateNow.ToString()) + new List(), k_DateNow.ToString()) }); @@ -370,7 +368,7 @@ public async Task Reconcile_Deletes() SetupList(new List() { - new(m_MockNonDuplicateModule.Name.ToString(), Language.JS, new Dictionary(), "url", DateNow, DateNow) + new(m_MockNonDuplicateModule.Name.ToString(), "JS", new Dictionary(), "url", k_DateNow, k_DateNow) }, new List() { m_MockNonDuplicateModule }); SetupDelete(); @@ -412,7 +410,7 @@ public void Reconcile_Delete_Throws() SetupList(new List() { - new(m_MockNonDuplicateModule.Name.ToString(), Language.JS, new Dictionary(), "url", DateNow, DateNow) + new(m_MockNonDuplicateModule.Name.ToString(), "JS", new Dictionary(), "url", k_DateNow, k_DateNow) }, new List() { m_MockNonDuplicateModule }); SetupDelete(true); @@ -469,7 +467,7 @@ void SetupGet(Module module, bool throws = false) } var getModuleResponse = new GetModuleResponse(module.Name.ToString(), - Language.JS); + "JS"); setup.ReturnsAsync(getModuleResponse); } 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 0e4bd64..06d09a6 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 @@ -48,26 +48,26 @@ class ImportScriptsHandlerTests readonly Mock m_MockArchiver = new(); readonly Mock m_MockLoadingIndicator = new(); - readonly static DateTime DateNow = DateTime.Now; + readonly static DateTime k_DateNow = DateTime.Now; readonly IEnumerable m_ScriptsListSingleScriptResponse = new List() { - new("test1", ScriptType.API, Gateway.CloudCodeApiV1.Generated.Model.Language.JS, true, DateNow, 1), + new("test1", "API", "JS", true, k_DateNow, 1), }; - readonly CloudCodeScript m_MockNonDuplicateScript = new(new ScriptName("test"), Language.JS, "", "{}", new List(), DateNow.ToString()); + readonly CloudCodeScript m_MockNonDuplicateScript = new(new ScriptName("test"), Language.JS, "", "{}", new List(), k_DateNow.ToString()); // This mock script updates the existing script in m_MockScripts if used within tests - readonly CloudCodeScript m_MockScript = new(new ScriptName("test1"), Language.JS, "", "{}", new List(), DateNow.ToString()); + readonly CloudCodeScript m_MockScript = new(new ScriptName("test1"), Language.JS, "", "{}", new List(), k_DateNow.ToString()); readonly List m_MockScripts = new() { - new(new ScriptName("test1"), Language.JS, "", "{}", new List(), DateNow.ToString()), - new(new ScriptName("test2"), Language.JS, "","{}", new List(), DateNow.ToString()), - new(new ScriptName("test3"), Language.JS, "","{}", new List(), DateNow.ToString()), - new(new ScriptName("test4"), Language.JS, "","{}", new List(), DateNow.ToString()), + new(new ScriptName("test1"), Language.JS, "", "{}", new List(), k_DateNow.ToString()), + new(new ScriptName("test2"), Language.JS, "","{}", new List(), k_DateNow.ToString()), + new(new ScriptName("test3"), Language.JS, "","{}", new List(), k_DateNow.ToString()), + new(new ScriptName("test4"), Language.JS, "","{}", new List(), k_DateNow.ToString()), }; CloudCodeScriptsImporter? m_CloudCodeScriptsImporter; @@ -247,7 +247,7 @@ public async Task Reconcile_Deletes() Array.Empty()))); - SetupList(new List() { new(m_MockNonDuplicateScript.Name.ToString(), ScriptType.API, Gateway.CloudCodeApiV1.Generated.Model.Language.JS, true, DateNow, 1), }); + SetupList(new List() { new(m_MockNonDuplicateScript.Name.ToString(), "API", "JS", true, k_DateNow, 1), }); SetupGet(new List() { m_MockNonDuplicateScript }); SetupDelete(); @@ -335,8 +335,8 @@ void SetupCreate(int creations = 1) It.IsAny(), It.IsAny(), It.IsAny(), - ScriptType.API, - Gateway.CloudCodeApiV1.Generated.Model.Language.JS, + "API", + "JS", It.IsAny(), It.IsAny>(), It.IsAny())); @@ -379,12 +379,12 @@ void SetupGet(List scripts, bool throws = false) foreach (var script in scripts) { var getScriptResponse = new GetScriptResponse(script.Name.ToString(), - ScriptType.API, - Gateway.CloudCodeApiV1.Generated.Model.Language.JS, + "API", + "JS", activeScript: new GetScriptResponseActiveScript( script.Body, 1, - DateNow, + k_DateNow, new List()), new List(), new List()); @@ -440,8 +440,8 @@ void VerifyApiCalls(List apiCallTypes) It.IsAny(), It.IsAny(), It.IsAny(), - ScriptType.API, - Gateway.CloudCodeApiV1.Generated.Model.Language.JS, + "API", + "JS", It.IsAny(), It.IsAny>(), It.IsAny()), diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Mock/CloudCodeApiV1AsyncMock.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Mock/CloudCodeApiV1AsyncMock.cs index 0db82c5..6856e26 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Mock/CloudCodeApiV1AsyncMock.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Mock/CloudCodeApiV1AsyncMock.cs @@ -19,10 +19,10 @@ class CloudCodeApiV1AsyncMock public ListModulesResponse ListModulesResponse { get; } = new(new List(), ""); public GetScriptResponse GetResponse { get; set; } = new( - "", ScriptType.API, Language.JS, new GetScriptResponseActiveScript("", 1, _params: new()), new()); + "", "API", "JS", new GetScriptResponseActiveScript("", 1, _params: new()), new()); public GetModuleResponse GetModuleResponse { get; set; } = - new("", Language.CS); + new("", "CS"); public PublishScriptResponse PublishScriptAsyncResponse { get; } = new(1); public readonly PublishScriptRequest PublishScriptAsyncRequestPayload = new() @@ -109,7 +109,7 @@ public void SetUp() It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Model/OutputScriptTests.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Model/OutputScriptTests.cs index 352683b..023e795 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Model/OutputScriptTests.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Model/OutputScriptTests.cs @@ -13,8 +13,8 @@ class OutputScriptTests { GetScriptResponse m_GetScriptResponse = new( "", - ScriptType.API, - Language.JS, + "API", + "JS", new GetScriptResponseActiveScript("", 0, _params: new List()), new List(), new List()); @@ -23,8 +23,8 @@ class OutputScriptTests public void SetUp() { const string scriptName = "Test"; - const ScriptType scriptType = ScriptType.API; - const Language language = Language.JS; + const string scriptType = "API"; + const string language = "JS"; const string code = ""; const int version = 0; var dateTime = new DateTime(); 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 12f61a9..22fa13c 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 @@ -1,4 +1,3 @@ -using System.Collections.Generic; using System.IO; using System.Threading; using System.Threading.Tasks; @@ -8,9 +7,6 @@ using Unity.Services.Cli.CloudCode.Parameters; using Unity.Services.Cli.CloudCode.Service; using Unity.Services.Cli.Common.Exceptions; -using Unity.Services.Gateway.CloudCodeApiV1.Generated.Model; -using Language = Unity.Services.Gateway.CloudCodeApiV1.Generated.Model.Language; -using CloudCodeAuthoringLanguage = Unity.Services.CloudCode.Authoring.Editor.Core.Model.Language; namespace Unity.Services.Cli.CloudCode.UnitTest.Service; @@ -50,7 +46,7 @@ public void GetCloudCodeInputParserSucceed() [Test] public void ParseLanguageSucceed() { - const Language expectedLanguage = Language.JS; + const string expectedLanguage = "JS"; var input = new CloudCodeInput { ScriptLanguage = expectedLanguage.ToString() @@ -63,7 +59,7 @@ public void ParseLanguageSucceed() [TestCase("")] public void ParseLanguageNullOrEmptySucceed(string language) { - const Language expectedLanguage = Language.JS; + const string expectedLanguage = "JS"; var input = new CloudCodeInput { ScriptLanguage = language, @@ -72,20 +68,10 @@ public void ParseLanguageNullOrEmptySucceed(string language) Assert.AreEqual(expectedLanguage, resultLanguage); } - [Test] - public void ParseInvalidLanguageFailed() - { - var input = new CloudCodeInput - { - ScriptLanguage = "Invalid Language" - }; - Assert.Throws(() => m_CloudCodeInputParser.ParseLanguage(input)); - } - [Test] public void ParseScriptTypeSucceed() { - const ScriptType expected = ScriptType.API; + const string expected = "API"; var input = new CloudCodeInput { ScriptType = expected.ToString() @@ -98,7 +84,7 @@ public void ParseScriptTypeSucceed() [TestCase("")] public void ParseScriptTypeNullOrEmptySucceed(string type) { - const ScriptType expected = ScriptType.API; + const string expected = "API"; var input = new CloudCodeInput { ScriptType = type @@ -107,16 +93,6 @@ public void ParseScriptTypeNullOrEmptySucceed(string type) Assert.AreEqual(expected, result); } - [Test] - public void ParseInvalidScriptTypeFail() - { - var input = new CloudCodeInput - { - ScriptType = "Invalid Type" - }; - Assert.Throws(() => m_CloudCodeInputParser.ParseScriptType(input)); - } - [Test] public async Task LoadScriptCodeSucceed() { diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Service/CloudCodeServiceTests.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Service/CloudCodeServiceTests.cs index 075daee..8976179 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Service/CloudCodeServiceTests.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Service/CloudCodeServiceTests.cs @@ -54,8 +54,8 @@ public void SetUp() { new( k_TestScriptName, - ScriptType.API, - Language.JS, + "API", + "JS", lastPublishedDate: DateTime.Now, published: false, lastPublishedVersion: 0) @@ -65,7 +65,7 @@ public void SetUp() { new( k_TestModuleName, - Language.CS, + "CS", null, "url", DateTime.Now, @@ -77,8 +77,8 @@ public void SetUp() m_ExpectedGetScript = new GetScriptResponse( "foo", - ScriptType.API, - Language.JS, + "API", + "JS", new GetScriptResponseActiveScript("bar", 1, DateTime.Now, new List()), _params: new List(), versions: new List @@ -89,7 +89,7 @@ public void SetUp() m_ExpectedGetModule = new GetModuleResponse( "bar", - Language.CS, + "CS", null, "url", DateTime.Now, @@ -144,7 +144,7 @@ public async Task ListAsync_ValidParamsGetExpectedScriptList() a => a.ListScriptsAsync( TestValues.ValidProjectId, TestValues.ValidEnvironmentId, - CloudCodeService.k_ListLimit, + CloudCodeService.ListLimit, It.IsAny(), 0, CancellationToken.None), @@ -156,8 +156,8 @@ public async Task ListAsync_GetMoreThanLimitScriptListSucceed() { var withinLimitScriptPattern = new ListScriptsResponseResultsInner( "a", - ScriptType.API, - Language.JS, + "API", + "JS", lastPublishedDate: DateTime.Now, published: false, lastPublishedVersion: 0); @@ -165,21 +165,21 @@ public async Task ListAsync_GetMoreThanLimitScriptListSucceed() const string limitName = "b"; var limitScriptPattern = new ListScriptsResponseResultsInner( limitName, - ScriptType.API, - Language.JS, + "API", + "JS", lastPublishedDate: DateTime.Now, published: false, lastPublishedVersion: 0); var withinLimitScripts = new List(); - withinLimitScripts.AddRange(Enumerable.Repeat(withinLimitScriptPattern, CloudCodeService.k_ListLimit - 1)); + withinLimitScripts.AddRange(Enumerable.Repeat(withinLimitScriptPattern, CloudCodeService.ListLimit - 1)); withinLimitScripts.Add(limitScriptPattern); var withinLimitResponse = new ListScriptsResponse(withinLimitScripts, new ListScriptsResponseLinks("")); m_CloudCodeApiV1AsyncMock.DefaultApiAsyncObject.Setup( a => a.ListScriptsAsync( TestValues.ValidProjectId, TestValues.ValidEnvironmentId, - CloudCodeService.k_ListLimit, + CloudCodeService.ListLimit, null, 0, CancellationToken.None)) @@ -187,8 +187,8 @@ public async Task ListAsync_GetMoreThanLimitScriptListSucceed() var exceedLimitScriptPattern = new ListScriptsResponseResultsInner( "c", - ScriptType.API, - Language.JS, + "API", + "JS", lastPublishedDate: DateTime.Now, published: false, lastPublishedVersion: 0); @@ -205,7 +205,7 @@ public async Task ListAsync_GetMoreThanLimitScriptListSucceed() a => a.ListScriptsAsync( TestValues.ValidProjectId, TestValues.ValidEnvironmentId, - CloudCodeService.k_ListLimit, + CloudCodeService.ListLimit, limitName, 0, CancellationToken.None)) @@ -224,7 +224,7 @@ public async Task ListAsync_GetMoreThanLimitScriptListSucceed() a.ListScriptsAsync( TestValues.ValidProjectId, TestValues.ValidEnvironmentId, - CloudCodeService.k_ListLimit, + CloudCodeService.ListLimit, It.IsAny(), 0, CancellationToken.None), @@ -741,8 +741,8 @@ public void CreateAsync_InvalidEnvironmentOrProjectIdThrowsConfigValidationExcep k_InvalidProjectId, It.IsAny(), It.IsAny(), - It.IsAny(), - It.IsAny(), + It.IsAny(), + It.IsAny(), It.IsAny(), k_Parameters, CancellationToken.None)); @@ -760,8 +760,8 @@ public void CreateAsync_InvalidScriptNameOrCodeThrowsConfigValidationException(s It.IsAny(), It.IsAny(), scriptName, - It.IsAny(), - It.IsAny(), + It.IsAny(), + It.IsAny(), code, k_Parameters, CancellationToken.None)); @@ -779,15 +779,15 @@ public void CreateAsync_ValidInputCallsCreateScript() }; var createRequest = new CreateScriptRequest( - k_TestScriptName, ScriptType.API, scriptParamList, k_NonEmptyCode, Language.JS); + k_TestScriptName, "API", scriptParamList, k_NonEmptyCode, "JS"); Assert.DoesNotThrowAsync( () => m_CloudCodeService!.CreateAsync( It.IsAny(), It.IsAny(), k_TestScriptName, - ScriptType.API, - Language.JS, + "API", + "JS", k_NonEmptyCode, scriptParamList, CancellationToken.None)); @@ -960,7 +960,7 @@ public void UpdateModuleAsync_ValidInputCallsCreatesModule_ThrowsOnUpdate() m_CloudCodeApiV1AsyncMock.DefaultApiAsyncObject.Verify( ex => ex.CreateModuleAsync( - It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), stream, 0, CancellationToken.None), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), stream, 0, CancellationToken.None), Times.Once); } @@ -988,7 +988,7 @@ public void UpdateModuleAsync_ValidInputCallsUpdatesModule() m_CloudCodeApiV1AsyncMock.DefaultApiAsyncObject.Verify( ex => ex.CreateModuleAsync( - It.IsAny(), It.IsAny(), "", Language.CS, stream,0, CancellationToken.None), + It.IsAny(), It.IsAny(), "", "CS", stream,0, CancellationToken.None), Times.Never); } @@ -1025,7 +1025,7 @@ public void UpdateModuleAsync_ThrowsWhenNon404ApiExceptionInUpdateCall() m_CloudCodeApiV1AsyncMock.DefaultApiAsyncObject.Verify( ex => ex.CreateModuleAsync( - It.IsAny(), It.IsAny(), "", Language.CS, stream, 0, CancellationToken.None), + It.IsAny(), It.IsAny(), "", "CS", stream, 0, CancellationToken.None), Times.Never); } @@ -1053,7 +1053,7 @@ public void UpdateModuleAsync_ThrowsWhenApiExceptionInCreateCall() It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), + It.IsAny(), stream, It.IsAny(), It.IsAny())) @@ -1075,7 +1075,7 @@ public void UpdateModuleAsync_ThrowsWhenApiExceptionInCreateCall() m_CloudCodeApiV1AsyncMock.DefaultApiAsyncObject.Verify( ex => ex.CreateModuleAsync( - It.IsAny(), It.IsAny(), k_TestModuleName, It.IsAny(), stream, 0, CancellationToken.None), + It.IsAny(), It.IsAny(), k_TestModuleName, It.IsAny(), stream, 0, CancellationToken.None), Times.Once); } diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Unity.Services.Cli.CloudCode.UnitTest.csproj b/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Unity.Services.Cli.CloudCode.UnitTest.csproj index 1057f74..b42a1cb 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Unity.Services.Cli.CloudCode.UnitTest.csproj +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudCode.UnitTest/Unity.Services.Cli.CloudCode.UnitTest.csproj @@ -21,7 +21,7 @@ - + diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Authoring/Clients/CloudCodeModuleClient.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Authoring/Clients/CloudCodeModuleClient.cs index 1f34b85..adee655 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Authoring/Clients/CloudCodeModuleClient.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Authoring/Clients/CloudCodeModuleClient.cs @@ -2,7 +2,6 @@ using Unity.Services.Cli.CloudCode.Service; using Unity.Services.CloudCode.Authoring.Editor.Core.Model; using Unity.Services.Gateway.CloudCodeApiV1.Generated.Model; -using Language = Unity.Services.Gateway.CloudCodeApiV1.Generated.Model.Language; namespace Unity.Services.Cli.CloudCode.Deploy; @@ -76,7 +75,7 @@ public async Task> ListScripts() ScriptInfo ConvertToScriptInfo(ListModulesResponseResultsInner result) { - var extension = result.Language == Language.CS ? ".ccm" : ""; + var extension = result.Language == "CS" ? ".ccm" : ""; var scriptInfo = new ScriptInfo(result.Name, extension); return scriptInfo; } diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Authoring/Clients/CloudCodeScriptClient.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Authoring/Clients/CloudCodeScriptClient.cs index b00e6f5..f2885ca 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Authoring/Clients/CloudCodeScriptClient.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Authoring/Clients/CloudCodeScriptClient.cs @@ -7,7 +7,6 @@ using Unity.Services.CloudCode.Authoring.Editor.Core.Model; using Unity.Services.Gateway.CloudCodeApiV1.Generated.Client; using Unity.Services.Gateway.CloudCodeApiV1.Generated.Model; -using Language = Unity.Services.Gateway.CloudCodeApiV1.Generated.Model.Language; namespace Unity.Services.Cli.CloudCode.Authoring; @@ -72,8 +71,8 @@ await m_CloudCodeService.CreateAsync( ProjectId, EnvironmentId, scriptNameWithoutExt, - ScriptType.API, - Language.JS, + "API", + "JS", code, parametersParsingResult.Parameters, CancellationToken); @@ -139,7 +138,7 @@ public async Task> ListScripts() ScriptInfo ConvertToScriptInfo(ListScriptsResponseResultsInner result) { - var extension = result.Language == Language.JS ? ".js" : ""; + var extension = result.Language == "JS" ? ".js" : ""; var scriptInfo = new ScriptInfo(result.Name, extension, result.LastPublishedDate.ToString()); return scriptInfo; } 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 b274082..0c61dcb 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 @@ -2,7 +2,6 @@ using Microsoft.Extensions.Logging; using Newtonsoft.Json; using Newtonsoft.Json.Linq; -using Newtonsoft.Json.Serialization; using Unity.Services.Cli.Authoring.Compression; using Unity.Services.Cli.Authoring.Import; using Unity.Services.Cli.Authoring.Model; @@ -12,8 +11,6 @@ using Unity.Services.Cli.Common.Exceptions; using Unity.Services.Cli.Common.Utils; using Unity.Services.Gateway.CloudCodeApiV1.Generated.Client; -using Unity.Services.Gateway.CloudCodeApiV1.Generated.Model; -using Language = Unity.Services.Gateway.CloudCodeApiV1.Generated.Model.Language; namespace Unity.Services.Cli.CloudCode.Handlers.ImportExport.Scripts; @@ -86,8 +83,8 @@ await m_CloudCodeService.CreateAsync( projectId, environmentId, script.Name.ToString(), - ScriptType.API, - Language.JS, + "API", + "JS", script.Body, parameters, cancellationToken); diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Model/GetModuleResponseOutput.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Model/GetModuleResponseOutput.cs index fdce59b..775fe76 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Model/GetModuleResponseOutput.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Model/GetModuleResponseOutput.cs @@ -8,7 +8,7 @@ namespace Unity.Services.Cli.CloudCode.Model; class GetModuleResponseOutput { public string Name { get; } - public Language Language { get; } + public string Language { get; } public String DateModified { get; } public String DateCreated { get; } diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Model/GetScriptResponseOutput.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Model/GetScriptResponseOutput.cs index 438c89d..c9e6dce 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Model/GetScriptResponseOutput.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Model/GetScriptResponseOutput.cs @@ -7,8 +7,8 @@ namespace Unity.Services.Cli.CloudCode.Model; class GetScriptResponseOutput { public string Name { get; } - public Language Language { get; } - public ScriptType Type { get; } + public string Language { get; } + public string Type { get; } public List Versions { get; } 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 b66610b..cb71ad3 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Service/CloudCodeInputParser.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Service/CloudCodeInputParser.cs @@ -1,8 +1,6 @@ using Unity.Services.Cli.CloudCode.Input; using Unity.Services.Cli.CloudCode.Parameters; using Unity.Services.Cli.Common.Exceptions; -using Unity.Services.Gateway.CloudCodeApiV1.Generated.Model; -using Language = Unity.Services.Gateway.CloudCodeApiV1.Generated.Model.Language; using CloudCodeAuthoringLanguage = Unity.Services.CloudCode.Authoring.Editor.Core.Model.Language; namespace Unity.Services.Cli.CloudCode.Service; @@ -20,42 +18,24 @@ public CloudCodeInputParser(ICloudCodeScriptParser cloudCodeScriptParser) { [CloudCodeAuthoringLanguage.JS] = "js" }; - public Language ParseLanguage(CloudCodeInput input) + public string ParseLanguage(CloudCodeInput input) { if (string.IsNullOrEmpty(input.ScriptLanguage)) { - return Language.JS; + return "JS"; } - try - { - return Enum.Parse(input.ScriptLanguage); - } - catch (ArgumentException) - { - var languages = String.Join(",", Enum.GetNames()); - throw new CliException($"'{input.ScriptLanguage}' is not a valid {nameof(Language)}." + - $" Valid {nameof(Language)}: " + languages + ".", ExitCode.HandledError); - } + return input.ScriptLanguage; } - public ScriptType ParseScriptType(CloudCodeInput input) + public string ParseScriptType(CloudCodeInput input) { if (string.IsNullOrEmpty(input.ScriptType)) { - return ScriptType.API; + return "API"; } - try - { - return Enum.Parse(input.ScriptType); - } - catch (ArgumentException) - { - var types = String.Join(",", Enum.GetNames()); - throw new CliException($"'{input.ScriptType}' is not a valid {nameof(ScriptType)}." + - $" Valid {nameof(ScriptType)}: " + types + ".", ExitCode.HandledError); - } + return input.ScriptType; } public async Task LoadScriptCodeAsync(CloudCodeInput input, CancellationToken cancellationToken) diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Service/CloudCodeService.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Service/CloudCodeService.cs index 31d5a21..5b10b37 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Service/CloudCodeService.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Service/CloudCodeService.cs @@ -8,7 +8,6 @@ using Unity.Services.Gateway.CloudCodeApiV1.Generated.Api; using Unity.Services.Gateway.CloudCodeApiV1.Generated.Client; using Unity.Services.Gateway.CloudCodeApiV1.Generated.Model; -using Language = Unity.Services.Gateway.CloudCodeApiV1.Generated.Model.Language; namespace Unity.Services.Cli.CloudCode.Service; @@ -18,8 +17,8 @@ class CloudCodeService : ICloudCodeService readonly ICloudCodeApiAsync m_CloudCodeAsyncApi; readonly IConfigurationValidator m_ConfigValidator; - internal const int k_ListLimit = 100; - internal const int k_MaxFileSizeLimitInMB = 10; + internal const int ListLimit = 100; + internal const int MaxFileSizeLimitInMb = 10; public CloudCodeService(ICloudCodeApiAsync cloudCodeAsyncApi, IConfigurationValidator validator, IServiceAccountAuthenticationService authenticationService) @@ -43,11 +42,11 @@ public async Task> ListAsync(string do { - var response = await m_CloudCodeAsyncApi.ListScriptsAsync(projectId, environmentId, k_ListLimit, + var response = await m_CloudCodeAsyncApi.ListScriptsAsync(projectId, environmentId, ListLimit, afterScript?.Name, cancellationToken: cancellationToken); var responseList = response.Results.ToList(); resultsCount = responseList.Count; - if (resultsCount < k_ListLimit) + if (resultsCount < ListLimit) { results.AddRange(responseList); break; @@ -55,7 +54,7 @@ public async Task> ListAsync(string afterScript = responseList[^1]; results.AddRange(responseList.SkipLast(1)); - } while (resultsCount == k_ListLimit); + } while (resultsCount == ListLimit); return results; } @@ -107,7 +106,7 @@ public async Task GetAsync(string projectId, string environme /// public async Task CreateAsync(string projectId, string environmentId, string? scriptName, - ScriptType scriptType, Language scriptLanguage, string? code, IReadOnlyList scriptParameters, + string scriptType, string scriptLanguage, string? code, IReadOnlyList scriptParameters, CancellationToken cancellationToken) { m_ConfigValidator.ThrowExceptionIfConfigInvalid(Keys.ConfigKeys.ProjectId, projectId); @@ -193,7 +192,7 @@ await m_CloudCodeAsyncApi.CreateModuleAsync( projectId, environmentId, moduleName, - Language.CS, + "CS", moduleStream, cancellationToken: cancellationToken); } @@ -296,9 +295,9 @@ static void ThrowIfFileInvalid(Stream stream) ExitCode.HandledError); } - if (stream.Length > 1024 * 1024 * k_MaxFileSizeLimitInMB) + if (stream.Length > 1024 * 1024 * MaxFileSizeLimitInMb) { - throw new CliException($"Module could not be updated because the file provided is over the size limit of {k_MaxFileSizeLimitInMB}MB.", + throw new CliException($"Module could not be updated because the file provided is over the size limit of {MaxFileSizeLimitInMb}MB.", ExitCode.HandledError); } } diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Service/ICloudCodeInputParser.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Service/ICloudCodeInputParser.cs index 8715f53..2ce933e 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Service/ICloudCodeInputParser.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Service/ICloudCodeInputParser.cs @@ -1,7 +1,5 @@ using Unity.Services.Cli.CloudCode.Input; using Unity.Services.Cli.CloudCode.Parameters; -using Unity.Services.Gateway.CloudCodeApiV1.Generated.Model; -using Language = Unity.Services.Gateway.CloudCodeApiV1.Generated.Model.Language; namespace Unity.Services.Cli.CloudCode.Service; @@ -9,9 +7,9 @@ interface ICloudCodeInputParser { public ICloudCodeScriptParser CloudCodeScriptParser { get; } - public Language ParseLanguage(CloudCodeInput input); + public string ParseLanguage(CloudCodeInput input); - public ScriptType ParseScriptType(CloudCodeInput input); + public string ParseScriptType(CloudCodeInput input); public Task LoadScriptCodeAsync(CloudCodeInput input, CancellationToken cancellationToken); diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Service/ICloudCodeService.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Service/ICloudCodeService.cs index f8a3192..3f11872 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Service/ICloudCodeService.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudCode/Service/ICloudCodeService.cs @@ -1,5 +1,4 @@ using Unity.Services.Gateway.CloudCodeApiV1.Generated.Model; -using Language = Unity.Services.Gateway.CloudCodeApiV1.Generated.Model.Language; namespace Unity.Services.Cli.CloudCode.Service; @@ -62,7 +61,7 @@ public Task GetAsync(string projectId, string environmentId, /// token to cancel the task /// public Task CreateAsync(string projectId, string environmentId, string? scriptName, - ScriptType scriptType, Language scriptLanguage, string? code, + string scriptType, string scriptLanguage, string? code, IReadOnlyList scriptParameters, CancellationToken cancellationToken); /// 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 89c436b..a32aed8 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,8 +21,8 @@ + - diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudContentDelivery/Model/SyncEntry.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudContentDelivery/Model/SyncEntry.cs index 0ad4e91..fc44e7e 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.CloudContentDelivery/Model/SyncEntry.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudContentDelivery/Model/SyncEntry.cs @@ -7,7 +7,7 @@ public SyncEntry( string environmentId = "", string bucketId = "", string projectId = "", - long? contentSize = null, + long contentSize = 0L, string contentType = "", string contentHash = "", string? entryId = "", @@ -19,7 +19,7 @@ public SyncEntry( EnvironmentId = environmentId; BucketId = bucketId; ProjectId = projectId; - ContentSize = contentSize!; + ContentSize = contentSize; ContentType = contentType; ContentHash = contentHash; Labels = labels; @@ -29,7 +29,7 @@ public SyncEntry( } public string Path { get; } - public long? ContentSize { get; } + public long ContentSize { get; } public string ContentType { get; } public string ContentHash { get; } public string EnvironmentId { get; } diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudContentDelivery/Service/SynchronizationService.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudContentDelivery/Service/SynchronizationService.cs index d90680d..47b089b 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.CloudContentDelivery/Service/SynchronizationService.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudContentDelivery/Service/SynchronizationService.cs @@ -243,7 +243,7 @@ public SyncResult CalculateDifference( environmentId, bucketId, projectId, - null, + 0L, "", "", remoteEntry.Entryid.ToString(), @@ -421,7 +421,7 @@ await ThrottledRetryPolicyAsync( var ccdCreateOrUpdateEntryBatchRequestInner = entryBatch.Select( entry => new CcdCreateOrUpdateEntryBatchRequestInner( entry.ContentHash, - (int)entry.ContentSize!, + entry.ContentSize, entry.ContentType, entry.Labels ?? new List(), entry.Metadata ?? "", @@ -582,7 +582,7 @@ public static IOperationSummary CalculateOperationSummary( var totalFilesUploaded = 0; foreach (var entry in syncResult.EntriesToAdd.Concat(syncResult.EntriesToUpdate)) { - totalUploadedSizeInBytes += entry.ContentSize ?? 0; + totalUploadedSizeInBytes += entry.ContentSize; totalFilesUploaded++; } diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudContentDelivery/Service/UploadContentClient.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudContentDelivery/Service/UploadContentClient.cs index 49719e7..8fdb890 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.CloudContentDelivery/Service/UploadContentClient.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudContentDelivery/Service/UploadContentClient.cs @@ -10,7 +10,9 @@ public class UploadContentClient : IUploadContentClient public UploadContentClient(HttpClient httpClient) { m_HttpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); - m_HttpClient.Timeout = TimeSpan.FromSeconds(900); + + // Because very large upload can take hours due to a poor connection, we have opted not to impose a timeout to give a chance to anyone to upload their content irrespective of the time it takes. + m_HttpClient.Timeout = Timeout.InfiniteTimeSpan; } public string GetContentType(string localPath) diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudSave.UnitTest/Authoring/CloudSaveDeploymentServiceTests.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudSave.UnitTest/Authoring/CloudSaveDeploymentServiceTests.cs new file mode 100644 index 0000000..4378aef --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudSave.UnitTest/Authoring/CloudSaveDeploymentServiceTests.cs @@ -0,0 +1,160 @@ +using Moq; +using Newtonsoft.Json; +using NUnit.Framework; +using Unity.Services.Cli.Authoring.Input; +using Unity.Services.Cli.CloudSave.Deploy; +using Unity.Services.DeploymentApi.Editor; +using Unity.Services.CloudSave.Authoring.Core.Deploy; +using Unity.Services.CloudSave.Authoring.Core.IO; +using Unity.Services.CloudSave.Authoring.Core.Model; +using Unity.Services.CloudSave.Authoring.Core.Service; + +namespace Unity.Services.Cli.CloudSave.UnitTest.Authoring; + +[TestFixture] +public class CloudSaveDeploymentServiceTests +{ + readonly Mock m_MockClient = new(); + readonly Mock m_MockDeploymentHandler = new(); + readonly Mock m_MockLoader = new(); + CloudSaveDeploymentService? m_DeploymentService; + Dictionary? m_Configs; + + [SetUp] + public void SetUp() + { + m_MockClient.Reset(); + m_DeploymentService = new CloudSaveDeploymentService( + m_MockDeploymentHandler.Object, + m_MockClient.Object, + m_MockLoader.Object); + + var config1 = new SimpleResourceDeploymentItem($"first_conf{Constants.SimpleFileExtension}"); + var config2 = new SimpleResourceDeploymentItem($"second_conf{Constants.SimpleFileExtension}"); + m_Configs = new[] + { + config1, + config2 + } + .ToDictionary(c => c.Path, c => c); + + m_MockLoader + .Setup( + m => + m.ReadResource( + It.IsAny(), + It.IsAny()) + ) + .Returns( + (string s, CancellationToken c) => + { + if (m_Configs.TryGetValue(s, out var res)) + { + return Task.FromResult((IResourceDeploymentItem)res); + } + + throw new IOException($"'{s}' not found"); + }); + + + m_MockDeploymentHandler.Setup( + d => d.DeployAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny() + )) + .ReturnsAsync( + () => + { + config2.SetStatusSeverity(SeverityLevel.Success); + config1.SetStatusSeverity(SeverityLevel.Success); + config1.SetStatusDetail(Constants.Created); + config2.SetStatusDetail(Constants.Updated); + return new DeployResult + { + Deployed = new[] + { + config1, + config2 + } + }; + }); + } + + [Test] + public async Task DeployAsync_MapsResult() + { + var input = new DeployInput() + { + Paths = Array.Empty(), + CloudProjectId = string.Empty + }; + + var res = await m_DeploymentService!.Deploy( + input, + new[] + { + $"first_conf{Constants.SimpleFileExtension}", + $"second_conf{Constants.SimpleFileExtension}" + }, + String.Empty, + string.Empty, + null, + CancellationToken.None); + + Assert.Multiple( + () => + { + Assert.That(res.Created, Has.Count.EqualTo(1)); + Assert.That(res.Updated, Has.Count.EqualTo(1)); + Assert.That(res.Deleted, Has.Count.EqualTo(0)); + Assert.That(res.Deployed, Has.Count.EqualTo(2)); + Assert.That(res.Failed, Has.Count.EqualTo(0)); + }); + } + + [Test] + public async Task DeployAsync_MapsFailed() + { + m_MockLoader + .Setup( + m => + m.ReadResource( + $"fail_path{Constants.SimpleFileExtension}", + It.IsAny()) + ) + .ReturnsAsync( + () => new SimpleResourceDeploymentItem($"fail_path{Constants.SimpleFileExtension}") + { + Status = new DeploymentStatus("Failed to read", "...", SeverityLevel.Error) + }); + + var input = new DeployInput() + { + CloudProjectId = string.Empty + }; + + var res = await m_DeploymentService!.Deploy( + input, + new[] + { + $"first_conf{Constants.SimpleFileExtension}", + $"second_conf{Constants.SimpleFileExtension}", + $"fail_path{Constants.SimpleFileExtension}" + }, + string.Empty, + string.Empty, + null, + CancellationToken.None); + Assert.Multiple( + () => + { + Assert.That(res.Created, Has.Count.EqualTo(1)); + Assert.That(res.Updated, Has.Count.EqualTo(1)); + Assert.That(res.Deleted, Has.Count.EqualTo(0)); + Assert.That(res.Deployed, Has.Count.EqualTo(2)); + Assert.That(res.Failed, Has.Count.EqualTo(1)); + }); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudSave.UnitTest/Authoring/CloudSaveFetchServiceTests.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudSave.UnitTest/Authoring/CloudSaveFetchServiceTests.cs new file mode 100644 index 0000000..c1119b5 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudSave.UnitTest/Authoring/CloudSaveFetchServiceTests.cs @@ -0,0 +1,164 @@ +using Moq; +using Newtonsoft.Json; +using NUnit.Framework; +using Unity.Services.Cli.Authoring.Input; +using Unity.Services.Cli.CloudSave.Deploy; +using Unity.Services.Cli.CloudSave.Fetch; +using Unity.Services.DeploymentApi.Editor; +using Unity.Services.CloudSave.Authoring.Core.Deploy; +using Unity.Services.CloudSave.Authoring.Core.Fetch; +using Unity.Services.CloudSave.Authoring.Core.IO; +using Unity.Services.CloudSave.Authoring.Core.Model; +using Unity.Services.CloudSave.Authoring.Core.Service; + +namespace Unity.Services.Cli.CloudSave.UnitTest.Authoring; + +[TestFixture] +public class CloudSaveFetchServiceTests +{ + readonly Mock m_MockClient = new(); + readonly Mock m_MockFetchHandler = new(); + readonly Mock m_MockLoader = new(); + CloudSaveFetchService? m_DeploymentService; + Dictionary? m_Configs; + + [SetUp] + public void SetUp() + { + m_MockClient.Reset(); + m_DeploymentService = new CloudSaveFetchService( + m_MockFetchHandler.Object, + m_MockClient.Object, + m_MockLoader.Object); + + var config1 = new SimpleResourceDeploymentItem($"first_conf{Constants.SimpleFileExtension}"); + var config2 = new SimpleResourceDeploymentItem($"second_conf{Constants.SimpleFileExtension}"); + m_Configs = new[] + { + config1, + config2 + } + .ToDictionary(c => c.Path, c => c); + + m_MockLoader + .Setup( + m => + m.ReadResource( + It.IsAny(), + It.IsAny()) + ) + .Returns( + (string s, CancellationToken c) => + { + if (m_Configs.TryGetValue(s, out var res)) + { + return Task.FromResult((IResourceDeploymentItem)res); + } + + throw new IOException($"'{s}' not found"); + }); + + + m_MockFetchHandler.Setup( + d => d.FetchAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny() + )) + .ReturnsAsync( + () => + { + config2.SetStatusSeverity(SeverityLevel.Success); + config1.SetStatusSeverity(SeverityLevel.Success); + config1.SetStatusDetail(Constants.Updated); + config2.SetStatusDetail(Constants.Deleted); + return new FetchResult() + { + Fetched = new[] + { + config1, + config2 + } + }; + }); + } + + [Test] + public async Task FetchAsync_MapsResult() + { + var input = new FetchInput() + { + Path = "rootdir", + CloudProjectId = string.Empty + }; + + var res = await m_DeploymentService!.FetchAsync( + input, + new[] + { + $"first_conf{Constants.SimpleFileExtension}", + $"second_conf{Constants.SimpleFileExtension}" + }, + String.Empty, + string.Empty, + null, + CancellationToken.None); + + Assert.Multiple( + () => + { + Assert.That(res.Created, Has.Count.EqualTo(0)); + Assert.That(res.Updated, Has.Count.EqualTo(1)); + Assert.That(res.Deleted, Has.Count.EqualTo(1)); + Assert.That(res.Fetched, Has.Count.EqualTo(2)); + Assert.That(res.Failed, Has.Count.EqualTo(0)); + }); + } + + [Test] + public async Task FetchAsync_MapsFailed() + { + m_MockLoader + .Setup( + m => + m.ReadResource( + $"fail_path{Constants.SimpleFileExtension}", + It.IsAny()) + ) + .ReturnsAsync( + () => new SimpleResourceDeploymentItem($"fail_path{Constants.SimpleFileExtension}") + { + Status = new DeploymentStatus("Failed to read", "...", SeverityLevel.Error) + }); + + var input = new FetchInput() + { + CloudProjectId = string.Empty + }; + + var res = await m_DeploymentService!.FetchAsync( + input, + new[] + { + $"first_conf{Constants.SimpleFileExtension}", + $"second_conf{Constants.SimpleFileExtension}", + $"fail_path{Constants.SimpleFileExtension}" + }, + string.Empty, + string.Empty, + null, + CancellationToken.None); + + Assert.Multiple( + () => + { + Assert.That(res.Created, Has.Count.EqualTo(0)); + Assert.That(res.Updated, Has.Count.EqualTo(1)); + Assert.That(res.Deleted, Has.Count.EqualTo(1)); + Assert.That(res.Fetched, Has.Count.EqualTo(2)); + Assert.That(res.Failed, Has.Count.EqualTo(1)); + }); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudSave.UnitTest/CloudSaveModuleTests.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudSave.UnitTest/CloudSaveModuleTests.cs new file mode 100644 index 0000000..52a94ad --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudSave.UnitTest/CloudSaveModuleTests.cs @@ -0,0 +1,120 @@ +using System.Reflection; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using NUnit.Framework; +using Unity.Services.Cli.CloudSave.Input; +using Unity.Services.Cli.Common.Input; +using Unity.Services.Cli.Common.Networking; +using Unity.Services.Cli.CloudSave.Service; +using Unity.Services.Cli.ServiceAccountAuthentication; +using Unity.Services.Gateway.CloudSaveApiV1.Generated.Api; + +namespace Unity.Services.Cli.CloudSave.UnitTest; + +[TestFixture] +class CloudSaveModuleTests +{ + [Test] + public void ListIndexesCommandWithInput() + { + CloudSaveModule module = new(); + + Assert.That(module.ListIndexesCommand.Options, Does.Contain(CommonInput.CloudProjectIdOption)); + Assert.That(module.ListIndexesCommand.Options, Does.Contain(CommonInput.EnvironmentNameOption)); + } + + [Test] + public void ListCustomIdsCommandWithInput() + { + CloudSaveModule module = new(); + + Assert.That(module.ListCustomDataIdsCommand.Options, Does.Contain(CommonInput.CloudProjectIdOption)); + Assert.That(module.ListCustomDataIdsCommand.Options, Does.Contain(CommonInput.EnvironmentNameOption)); + Assert.That(module.ListCustomDataIdsCommand.Options, Does.Contain(ListDataIdsInput.LimitOption)); + Assert.That(module.ListCustomDataIdsCommand.Options, Does.Contain(ListDataIdsInput.StartOption)); + } + + public void ListPlayerIdsCommandWithInput() + { + CloudSaveModule module = new(); + + Assert.That(module.ListPlayerDataIdsCommand.Options, Does.Contain(CommonInput.CloudProjectIdOption)); + Assert.That(module.ListPlayerDataIdsCommand.Options, Does.Contain(CommonInput.EnvironmentNameOption)); + Assert.That(module.ListPlayerDataIdsCommand.Options, Does.Contain(ListDataIdsInput.LimitOption)); + Assert.That(module.ListPlayerDataIdsCommand.Options, Does.Contain(ListDataIdsInput.StartOption)); + } + + [Test] + public void QueryPlayerDataCommandWithInput() + { + CloudSaveModule module = new(); + + Assert.That(module.QueryPlayerDataCommand.Options, Does.Contain(CommonInput.CloudProjectIdOption)); + Assert.That(module.QueryPlayerDataCommand.Options, Does.Contain(CommonInput.EnvironmentNameOption)); + Assert.That(module.QueryPlayerDataCommand.Options, Does.Contain(QueryDataInput.JsonFileOrBodyOption)); + Assert.That(module.QueryPlayerDataCommand.Options, Does.Contain(QueryDataInput.VisibilityOption)); + } + + [Test] + public void QueryCustomDataCommandWithInput() + { + CloudSaveModule module = new(); + + Assert.That(module.QueryCustomDataCommand.Options, Does.Contain(CommonInput.CloudProjectIdOption)); + Assert.That(module.QueryCustomDataCommand.Options, Does.Contain(CommonInput.EnvironmentNameOption)); + Assert.That(module.QueryCustomDataCommand.Options, Does.Contain(QueryDataInput.JsonFileOrBodyOption)); + Assert.That(module.QueryCustomDataCommand.Options, Does.Contain(QueryDataInput.VisibilityOption)); + } + + [Test] + public void CreatePlayerIndexCommandWithInput() + { + CloudSaveModule module = new(); + + Assert.That(module.CreatePlayerIndexCommand.Options, Does.Contain(CommonInput.CloudProjectIdOption)); + Assert.That(module.CreatePlayerIndexCommand.Options, Does.Contain(CommonInput.EnvironmentNameOption)); + Assert.That(module.CreatePlayerIndexCommand.Options, Does.Contain(CreateIndexInput.FieldsOption)); + Assert.That(module.CreatePlayerIndexCommand.Options, Does.Contain(CreateIndexInput.JsonFileOrBodyOption)); + Assert.That(module.CreatePlayerIndexCommand.Options, Does.Contain(CreateIndexInput.VisibilityOption)); + } + + [Test] + public void CreateCustomIndexCommandWithInput() + { + CloudSaveModule module = new(); + + Assert.That(module.CreateCustomIndexCommand.Options, Does.Contain(CommonInput.CloudProjectIdOption)); + Assert.That(module.CreateCustomIndexCommand.Options, Does.Contain(CommonInput.EnvironmentNameOption)); + Assert.That(module.CreateCustomIndexCommand.Options, Does.Contain(CreateIndexInput.FieldsOption)); + Assert.That(module.CreateCustomIndexCommand.Options, Does.Contain(CreateIndexInput.JsonFileOrBodyOption)); + Assert.That(module.CreateCustomIndexCommand.Options, Does.Contain(CreateIndexInput.VisibilityOption)); + } + + [TestCase(typeof(ICloudSaveDataService))] + public void ConfigureCloudSaveRegistersExpectedServices(Type serviceType) + { + EndpointHelper.InitializeNetworkTargetEndpoints(new[] + { + typeof(CloudSaveEndpoints).GetTypeInfo() + }); + + var collection = new ServiceCollection(); + collection.AddSingleton(ServiceDescriptor.Singleton(new Mock().Object)); + collection.AddSingleton(ServiceDescriptor.Singleton(new Mock().Object)); + CloudSaveModule.RegisterServices(collection); + Assert.That(collection.FirstOrDefault(c => c.ServiceType == serviceType), Is.Not.Null); + } + + [Test] + + public void RetryAfterSleepDuration() + { + var response = new RestSharp.RestResponse(); + response.Headers = new List() + { + new ("Retry-After", "1") + }; + Polly.DelegateResult res = new Polly.DelegateResult(response); + Assert.That(CloudSaveModule.RetryAfterSleepDuration(2, res, new Polly.Context()), Is.EqualTo(TimeSpan.FromSeconds(2))); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudSave.UnitTest/Core/CloudSaveDeployFetchTestBase.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudSave.UnitTest/Core/CloudSaveDeployFetchTestBase.cs new file mode 100644 index 0000000..597ae8b --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudSave.UnitTest/Core/CloudSaveDeployFetchTestBase.cs @@ -0,0 +1,53 @@ +using Unity.Services.CloudSave.Authoring.Core.Model; + +namespace Unity.Services.Cli.CloudSave.UnitTest.Core; + +public class CloudSaveDeployFetchTestBase +{ + protected static List GetLocalResources() + { + return new List() + { + new("one" + Constants.SimpleFileExtension) + { + Resource = new SimpleResource() + { + Id = "ID1" + } + }, + new("sub2/two" + Constants.SimpleFileExtension) + { + Resource = new SimpleResource() + { + Id = "ID2" + } + }, + new("sub2/three" + Constants.SimpleFileExtension) + { + Resource = new SimpleResource() + { + Id = "ID3" + } + } + }; + } + + protected static List GetRemoteResources() + { + return new List() + { + new() + { + Id = "ID3" + }, + new() + { + Id = "ID4" + }, + new() + { + Id = "ID5" + } + }; + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudSave.UnitTest/Core/CloudSaveDeploymentHandlerTests.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudSave.UnitTest/Core/CloudSaveDeploymentHandlerTests.cs new file mode 100644 index 0000000..dc8f08c --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudSave.UnitTest/Core/CloudSaveDeploymentHandlerTests.cs @@ -0,0 +1,477 @@ +using Moq; +using NUnit.Framework; +using Unity.Services.DeploymentApi.Editor; +using Unity.Services.CloudSave.Authoring.Core.Deploy; +using Unity.Services.CloudSave.Authoring.Core.IO; +using Unity.Services.CloudSave.Authoring.Core.Model; +using Unity.Services.CloudSave.Authoring.Core.Service; + +namespace Unity.Services.Cli.CloudSave.UnitTest.Core; + +[TestFixture] +public class CloudSaveDeploymentHandlerTests : CloudSaveDeployFetchTestBase +{ + [Test] + public async Task Test_NoDuplicates_NoProblem() + { + var mockClient = new Mock(); + mockClient.Setup(c => c.List(It.IsAny())) + .Returns(() => Task.FromResult((IReadOnlyList)Array.Empty())); + mockClient.Setup(c => c.Create(It.IsAny(), It.IsAny())) + .Returns(() => Task.CompletedTask); + mockClient.Setup(c => c.Update(It.IsAny(), It.IsAny())) + .Returns(() => Task.CompletedTask); + mockClient.Setup(c => c.Get(It.IsAny(), It.IsAny())) + .Returns( + () => Task.FromResult( + new SimpleResource + { + Id = "one" + })); + + var moduleTemplateClient = mockClient.Object; + var handler = new CloudSaveDeploymentHandler(moduleTemplateClient); + var localResources = new[] + { + new SimpleResourceDeploymentItem("one.serv") + { + Resource = new SimpleResource + { + Id = "one" + } + }, + new SimpleResourceDeploymentItem("two.serv") + { + Resource = new SimpleResource + { + Id = "two" + } + } + }; + var res = await handler.DeployAsync(localResources); + Assert.Multiple( + () => + { + Assert.That(res.Deployed.Count(r => r.Status.MessageSeverity == SeverityLevel.Success), Is.EqualTo(2)); + Assert.That(res.Deployed.Count(r => r.Status.MessageSeverity == SeverityLevel.Error), Is.EqualTo(0)); + }); + } + + [Test] + public async Task Test_TwoDuplicates_TwoFailed() + { + var mockClient = new Mock(); + mockClient.Setup(c => c.List(It.IsAny())) + .Returns(() => Task.FromResult((IReadOnlyList)Array.Empty())); + mockClient.Setup(c => c.Create(It.IsAny(), It.IsAny())) + .Returns(() => Task.CompletedTask); + mockClient.Setup(c => c.Update(It.IsAny(), It.IsAny())) + .Returns(() => Task.CompletedTask); + mockClient.Setup(c => c.Get(It.IsAny(), It.IsAny())) + .Returns( + () => Task.FromResult( + new SimpleResource + { + Id = "one" + })); + + var moduleTemplateClient = mockClient.Object; + var handler = new CloudSaveDeploymentHandler(moduleTemplateClient); + var res = await handler.DeployAsync( + new[] + { + new SimpleResourceDeploymentItem("one.serv") + { + Resource = new SimpleResource + { + Id = "one" + } + }, + new SimpleResourceDeploymentItem("sub1/one.serv") + { + Resource = new SimpleResource + { + Id = "one" + } + } + }); + Assert.Multiple( + () => + { + Assert.That(res.Deployed.Count(r => r.Status.MessageSeverity == SeverityLevel.Success), Is.EqualTo(0)); + Assert.That(res.Deployed.Count(r => r.Status.MessageSeverity == SeverityLevel.Error), Is.EqualTo(2)); + }); + } + + [Test] + public async Task Test_TwoDuplicates_OneDistinct() + { + var mockClient = new Mock(); + mockClient.Setup(c => c.List(It.IsAny())) + .Returns(() => Task.FromResult((IReadOnlyList)Array.Empty())); + mockClient.Setup(c => c.Create(It.IsAny(), It.IsAny())) + .Returns(() => Task.CompletedTask); + mockClient.Setup(c => c.Update(It.IsAny(), It.IsAny())) + .Returns(() => Task.CompletedTask); + mockClient.Setup(c => c.Get(It.IsAny(), It.IsAny())) + .Returns( + () => Task.FromResult( + new SimpleResource + { + Id = "one" + })); + + var handler = new CloudSaveDeploymentHandler(mockClient.Object); + var res = await handler.DeployAsync( + new[] + { + new SimpleResourceDeploymentItem("one.serv") + { + Resource = new SimpleResource() + { + Id = "one" + } + }, + new SimpleResourceDeploymentItem("sub1/one.serv") + { + Resource = new SimpleResource() + { + Id = "one" + } + }, + new SimpleResourceDeploymentItem("sub2/two.serv") + { + Resource = new SimpleResource() + { + Id = "two" + } + } + }); + + Assert.Multiple( + () => + { + Assert.That(res.Deployed.Count(r => r.Status.MessageSeverity == SeverityLevel.Success), Is.EqualTo(1)); + Assert.That(res.Deployed.Count(r => r.Status.MessageSeverity == SeverityLevel.Error), Is.EqualTo(2)); + }); + } + + [Test] + public async Task DeployAsync_CreateCallsMade() + { + // 1, 2 ,3 + var localResources = GetLocalResources(); + // 3, 4, 5 + var remoteResources = GetRemoteResources(); + + Mock mockClient = new(); + var handler = new CloudSaveDeploymentHandler(mockClient.Object); + + mockClient + .Setup(c => c.List(It.IsAny())) + .ReturnsAsync(remoteResources.ToList()); + + var actualRes = await handler.DeployAsync( + localResources + ); + + mockClient + .Verify( + c => c.Create( + It.Is(l => l.Id == "ID1"), + It.IsAny()), + Times.Once); + mockClient + .Verify( + c => c.Create( + It.Is(l => l.Id == "ID2"), + It.IsAny()), + Times.Once); + } + + [Test] + public async Task DeployAsync_UpdateCallsMade() + { + // 1, 2 ,3 + var localResources = GetLocalResources(); + // 3, 4, 5 + var remoteResources = GetRemoteResources(); + + Mock mockClient = new(); + var handler = new CloudSaveDeploymentHandler(mockClient.Object); + + mockClient + .Setup(c => c.List(It.IsAny())) + .ReturnsAsync(remoteResources.ToList()); + + var actualRes = await handler.DeployAsync( + localResources + ); + + mockClient + .Verify( + c => c.Update( + It.Is(l => l.Id == "ID3"), + It.IsAny()), + Times.Once); + } + + + [Test] + public async Task DeployAsync_NoReconcileNoDeleteCalls() + { + // 1, 2 ,3 + var localResources = GetLocalResources(); + // 3, 4, 5 + var remoteResources = GetRemoteResources(); + + Mock mockClient = new(); + var handler = new CloudSaveDeploymentHandler(mockClient.Object); + + mockClient + .Setup(c => c.List(It.IsAny())) + .ReturnsAsync(remoteResources.ToList()); + + var actualRes = await handler.DeployAsync( + localResources + ); + + mockClient + .Verify( + c => c.Delete( + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Test] + public async Task DeployAsync_ReconcileDeleteCalls() + { + // 1, 2 ,3 + var localResources = GetLocalResources(); + // 3, 4, 5 + var remoteResources = GetRemoteResources(); + + Mock mockClient = new(); + var handler = new CloudSaveDeploymentHandler(mockClient.Object); + + mockClient + .Setup(c => c.List(It.IsAny())) + .ReturnsAsync(remoteResources.ToList()); + + var actualRes = await handler.DeployAsync( + localResources, + reconcile: true + ); + + mockClient + .Verify( + c => c.Delete( + It.Is(l => l.Id == "ID4"), + It.IsAny()), + Times.Once); + mockClient + .Verify( + c => c.Delete( + It.Is(l => l.Id == "ID5"), + It.IsAny()), + Times.Once); + } + + [Test] + [TestCase(true)] + [TestCase(false)] + public async Task DeployAsync_DryRunCorrectResult(bool reconcile) + { + var localResources = GetLocalResources(); + var remoteResources = GetRemoteResources(); + + Mock mockClient = new(); + var handler = new CloudSaveDeploymentHandler(mockClient.Object); + + mockClient + .Setup(c => c.List(It.IsAny())) + .ReturnsAsync(remoteResources.ToList()); + + var actualRes = await handler.DeployAsync( + localResources, + dryRun: true, + reconcile: reconcile + ); + + Assert.That( + actualRes.Deployed.ToList(), Does.Contain(actualRes.Deployed.FirstOrDefault( + l => l.Resource.Id == "ID1" && l.Status.MessageDetail.StartsWith(Constants.Created))), + "Item marked for creation is missing or incorrectly labeled"); + Assert.That( + actualRes.Deployed.ToList(), Does.Contain(actualRes.Deployed.FirstOrDefault( + l => l.Resource.Id == "ID2" && l.Status.MessageDetail.StartsWith(Constants.Created))), + "Item marked for creation is missing or incorrectly labeled"); + Assert.That( + actualRes.Deployed.ToList(), Does.Contain(actualRes.Deployed.FirstOrDefault( + l => l.Resource.Id == "ID3" && l.Status.MessageDetail.StartsWith(Constants.Updated))), + "Item marked for update is missing or incorrectly labeled"); + if (reconcile) + { + Assert.Multiple(() => + { + Assert.That( + actualRes.Deployed.ToList(), Does.Contain(actualRes.Deployed.FirstOrDefault( + l => l.Resource.Id == "ID4" && l.Status.MessageDetail.StartsWith(Constants.Deleted))), + "Item marked for deletion is missing or incorrectly labeled"); + Assert.That( + actualRes.Deployed.ToList(), Does.Contain(actualRes.Deployed.FirstOrDefault( + l => l.Resource.Id == "ID5" && l.Status.MessageDetail.StartsWith(Constants.Deleted))), + "Item marked for deletion is missing or incorrectly labeled"); + Assert.That(actualRes.Deployed, Has.Count.EqualTo(5)); + }); + } + else + { + Assert.That(actualRes.Deployed, Has.Count.EqualTo(3)); + } + } + + + [Test] + [TestCase(true)] + [TestCase(false)] + public async Task DeployAsync_DryRunNoMutatingCalls(bool reconcile) + { + var localResources = GetLocalResources(); + var remoteResources = GetRemoteResources(); + + Mock mockClient = new(); + var handler = new CloudSaveDeploymentHandler(mockClient.Object); + + mockClient + .Setup(c => c.List(It.IsAny())) + .ReturnsAsync(remoteResources.ToList()); + + var actualRes = await handler.DeployAsync( + localResources, + dryRun: true, + reconcile: reconcile + ); + + mockClient + .Verify( + l => l.Create( + It.IsAny(), + It.IsAny()), + Times.Never); + mockClient + .Verify( + l => l.Update( + It.IsAny(), + It.IsAny()), + Times.Never); + mockClient + .Verify( + l => l.Delete( + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Test] + [TestCase(false, false)] + [TestCase(false, true)] + [TestCase(true, false)] + [TestCase(true, true)] + public async Task DeployAsync_DuplicateIdNotDeleted(bool dryRun, bool reconcile) + { + // 1, 2, 3 + var localResources = GetLocalResources(); + localResources.Add( + new SimpleResourceDeploymentItem("sub2/one.serv") + { + Resource = new SimpleResource() + { + Id = "ID1" + } + } + ); + //3, 4, 5 + var remoteResources = GetRemoteResources(); + + Mock mockClient = new(); + var handler = new CloudSaveDeploymentHandler(mockClient.Object); + + mockClient + .Setup(c => c.List(It.IsAny())) + .ReturnsAsync(remoteResources.ToList()); + + var actualRes = await handler.DeployAsync( + localResources, + dryRun: dryRun, + reconcile: reconcile + ); + + var failed = actualRes + .Deployed + .Where(i => i.Status.MessageSeverity == SeverityLevel.Error) + .ToList(); + Assert.Multiple(() => + { + Assert.That(failed.Count(l => l.Resource.Id == "ID1"), Is.EqualTo(2)); + Assert.That(failed.FirstOrDefault(l => l.Path == "sub2/one.serv"), Is.Not.Null); + Assert.That(failed.FirstOrDefault(l => l.Path == "one.serv"), Is.Not.Null); + }); + mockClient.Verify(c => c.Delete(failed[0].Resource, It.IsAny()), Times.Never()); + mockClient.Verify(c => c.Delete(failed[1].Resource, It.IsAny()), Times.Never()); + } + + [Test] + [TestCase(false, false)] + [TestCase(false, true)] + [TestCase(true, false)] + [TestCase(true, true)] + public async Task DeployAsync_CorrectResults(bool dryRun, bool reconcile) + { + // 1, 2, 3 + var localResources = GetLocalResources(); + // 3, 4, 5 + var remoteResources = GetRemoteResources(); + + Mock mockClient = new(); + Mock mockLoader = new(); + var handler = new CloudSaveDeploymentHandler(mockClient.Object); + + mockClient + .Setup(c => c.List(It.IsAny())) + .ReturnsAsync(remoteResources.ToList()); + + var actualRes = await handler.DeployAsync( + localResources, + dryRun: true, + reconcile: reconcile + ); + + + Assert.That(actualRes.Deployed, Has.Count.EqualTo(reconcile ? 5 : 3)); + + foreach (var localConfig in localResources) + { + Assert.That(actualRes.Deployed.ToList(), Does.Contain(localConfig)); + } + + StringAssert.StartsWith(Constants.Created, actualRes.Deployed[0].Status.MessageDetail); + StringAssert.StartsWith(Constants.Created, actualRes.Deployed[1].Status.MessageDetail); + StringAssert.StartsWith(Constants.Updated, actualRes.Deployed[2].Status.MessageDetail); + + if (reconcile) + { + StringAssert.StartsWith(Constants.Deleted, actualRes.Deployed[3].Status.MessageDetail); + StringAssert.StartsWith(Constants.Deleted, actualRes.Deployed[4].Status.MessageDetail); + } + + if (!dryRun) + { + foreach (var localConfig in actualRes.Deployed) + { + Assert.That(localConfig.Status.MessageSeverity, Is.EqualTo(SeverityLevel.Success)); + } + } + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudSave.UnitTest/Core/CloudSaveFetchHandlerTests.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudSave.UnitTest/Core/CloudSaveFetchHandlerTests.cs new file mode 100644 index 0000000..3123617 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudSave.UnitTest/Core/CloudSaveFetchHandlerTests.cs @@ -0,0 +1,421 @@ +using Moq; +using NUnit.Framework; +using Unity.Services.DeploymentApi.Editor; +using Unity.Services.CloudSave.Authoring.Core.Deploy; +using Unity.Services.CloudSave.Authoring.Core.Fetch; +using Unity.Services.CloudSave.Authoring.Core.IO; +using Unity.Services.CloudSave.Authoring.Core.Model; +using Unity.Services.CloudSave.Authoring.Core.Service; + +namespace Unity.Services.Cli.CloudSave.UnitTest.Core; + +[TestFixture] +public class CloudSaveFetchHandlerTests : CloudSaveDeployFetchTestBase +{ + [Test] + public async Task Test_NoDuplicates_NoProblem() + { + var mockClient = new Mock(); + mockClient.Setup(c => c.List(It.IsAny())) + .Returns(() => Task.FromResult((IReadOnlyList)Array.Empty())); + mockClient.Setup(c => c.Create(It.IsAny(), It.IsAny())) + .Returns(() => Task.CompletedTask); + mockClient.Setup(c => c.Update(It.IsAny(), It.IsAny())) + .Returns(() => Task.CompletedTask); + mockClient.Setup(c => c.Get(It.IsAny(), It.IsAny())) + .Returns( + () => Task.FromResult( + new SimpleResource + { + Id = "one" + })); + + var moduleTemplateClient = mockClient.Object; + var handler = new CloudSaveDeploymentHandler(moduleTemplateClient); + var localResources = new[] + { + new SimpleResourceDeploymentItem("one.serv") + { + Resource = new SimpleResource + { + Id = "one" + } + }, + new SimpleResourceDeploymentItem("two.serv") + { + Resource = new SimpleResource + { + Id = "two" + } + } + }; + var res = await handler.DeployAsync(localResources); + Assert.Multiple( + () => + { + Assert.That(res.Deployed.Count(r => r.Status.MessageSeverity == SeverityLevel.Success), Is.EqualTo(2)); + Assert.That(res.Deployed.Count(r => r.Status.MessageSeverity == SeverityLevel.Error), Is.EqualTo(0)); + }); + } + + [Test] + public async Task Test_TwoDuplicates_TwoFailed() + { + var mockClient = new Mock(); + mockClient.Setup(c => c.List(It.IsAny())) + .Returns(() => Task.FromResult((IReadOnlyList)Array.Empty())); + mockClient.Setup(c => c.Create(It.IsAny(), It.IsAny())) + .Returns(() => Task.CompletedTask); + mockClient.Setup(c => c.Update(It.IsAny(), It.IsAny())) + .Returns(() => Task.CompletedTask); + mockClient.Setup(c => c.Get(It.IsAny(), It.IsAny())) + .Returns( + () => Task.FromResult( + new SimpleResource + { + Id = "one" + })); + + var moduleTemplateClient = mockClient.Object; + var handler = new CloudSaveDeploymentHandler(moduleTemplateClient); + var res = await handler.DeployAsync( + new[] + { + new SimpleResourceDeploymentItem("one.serv") + { + Resource = new SimpleResource + { + Id = "one" + } + }, + new SimpleResourceDeploymentItem("sub1/one.serv") + { + Resource = new SimpleResource + { + Id = "one" + } + } + }); + Assert.Multiple( + () => + { + Assert.That(res.Deployed.Count(r => r.Status.MessageSeverity == SeverityLevel.Success), Is.EqualTo(0)); + Assert.That(res.Deployed.Count(r => r.Status.MessageSeverity == SeverityLevel.Error), Is.EqualTo(2)); + }); + } + + [Test] + public async Task Test_TwoDuplicates_OneDistinct() + { + var mockClient = new Mock(); + mockClient.Setup(c => c.List(It.IsAny())) + .Returns(() => Task.FromResult((IReadOnlyList)Array.Empty())); + mockClient.Setup(c => c.Create(It.IsAny(), It.IsAny())) + .Returns(() => Task.CompletedTask); + mockClient.Setup(c => c.Update(It.IsAny(), It.IsAny())) + .Returns(() => Task.CompletedTask); + mockClient.Setup(c => c.Get(It.IsAny(), It.IsAny())) + .Returns( + () => Task.FromResult( + new SimpleResource + { + Id = "one" + })); + + var handler = new CloudSaveDeploymentHandler(mockClient.Object); + var res = await handler.DeployAsync( + new[] + { + new SimpleResourceDeploymentItem("one.serv") + { + Resource = new SimpleResource() + { + Id = "one" + } + }, + new SimpleResourceDeploymentItem("sub1/one.serv") + { + Resource = new SimpleResource() + { + Id = "one" + } + }, + new SimpleResourceDeploymentItem("sub2/two.serv") + { + Resource = new SimpleResource() + { + Id = "two" + } + } + }); + + Assert.Multiple( + () => + { + Assert.That(res.Deployed.Count(r => r.Status.MessageSeverity == SeverityLevel.Success), Is.EqualTo(1)); + Assert.That(res.Deployed.Count(r => r.Status.MessageSeverity == SeverityLevel.Error), Is.EqualTo(2)); + }); + } + + [Test] + public async Task FetchAsync_WriteCallsMade() + { + // 1, 2, 3 + var localResources = GetLocalResources(); + // 3, 4, 5 + var remoteResources = GetRemoteResources(); + + Mock mockClient = new(); + Mock mockLoader = new(); + var handler = new CloudSaveFetchHandler(mockClient.Object, mockLoader.Object); + + mockClient + .Setup(c => c.List(It.IsAny())) + .ReturnsAsync(remoteResources.ToList()); + + var actualRes = await handler.FetchAsync( + "dir", + localResources + ); + + var thirdRes = localResources.First(c => c.Resource.Id == "ID3"); + + mockLoader + .Verify( + f => f.CreateOrUpdateResource( + thirdRes, + It.IsAny()), + Times.Once); + + mockLoader + .Verify( + f => f.CreateOrUpdateResource( + localResources.First(c => c.Resource.Id != "ID3"), + It.IsAny()), + Times.Never, + "Something other than the expected file was written into"); + } + + [Test] + public async Task FetchAsync_DeleteCallsMade() + { + // 1, 2, 3 + var localResources = GetLocalResources(); + // 3, 4, 5 + var remoteResources = GetRemoteResources(); + + Mock mockClient = new(); + Mock mockLoader = new(); + var handler = new CloudSaveFetchHandler(mockClient.Object, mockLoader.Object); + + mockClient + .Setup(c => c.List(It.IsAny())) + .ReturnsAsync(remoteResources.ToList()); + + var actualRes = await handler.FetchAsync( + "dir", + localResources + ); + + var res1 = localResources.First(c => c.Resource.Id == "ID1"); + var res2 = localResources.First(c => c.Resource.Id == "ID2"); + + mockLoader + .Verify( + f => f.DeleteResource( + res1, + It.IsAny()), + Times.Once); + + mockLoader + .Verify( + f => f.DeleteResource( + res2, + It.IsAny()), + Times.Once); + } + + [Test] + public async Task FetchAsync_WriteNewOnReconcile() + { + // 1, 2, 3 + var localResources = GetLocalResources(); + // 3, 4, 5 + var remoteResources = GetRemoteResources(); + + Mock mockClient = new(); + Mock mockLoader = new(); + var handler = new CloudSaveFetchHandler(mockClient.Object, mockLoader.Object); + + mockClient + .Setup(c => c.List(It.IsAny())) + .ReturnsAsync(remoteResources.ToList()); + + var actualRes = await handler.FetchAsync( + "dir", + localResources, + reconcile: true + ); + + var writtenFileIds = new[] + { + "ID4", + "ID5" + }; + + foreach (var fileId in writtenFileIds) + { + mockLoader + .Verify( + f => f.CreateOrUpdateResource( + It.Is(i => i.Resource.Id == fileId), + It.IsAny()), + Times.Once); + } + } + + [Test] + [TestCase(true)] + [TestCase(false)] + public async Task FetchAsync_DryRunNoCalls(bool reconcile) + { + // 1, 2, 3 + var localResources = GetLocalResources(); + // 3, 4, 5 + var remoteResources = GetRemoteResources(); + + Mock mockClient = new(); + Mock mockLoader = new(); + var handler = new CloudSaveFetchHandler(mockClient.Object, mockLoader.Object); + + mockClient + .Setup(c => c.List(It.IsAny())) + .ReturnsAsync(remoteResources.ToList()); + + var actualRes = await handler.FetchAsync( + "dir", + localResources, + dryRun: true, + reconcile: reconcile + ); + + mockLoader + .Verify( + f => f.DeleteResource( + It.IsAny(), + It.IsAny()), + Times.Never); + + mockLoader + .Verify( + f => f.CreateOrUpdateResource( + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Test] + public async Task FetchAsync_DuplicateIdNotDeleted() + { + var localResources = GetLocalResources(); + var triggerConfig = new SimpleResourceDeploymentItem("other/my-thing.serv") + { + Resource = new SimpleResource + { + Id = "ID1" + } + }; + + localResources.Add(triggerConfig); + var remoteResources = GetRemoteResources(); + + Mock mockClient = new(); + Mock mockLoader = new(); + var handler = new CloudSaveFetchHandler(mockClient.Object, mockLoader.Object); + + mockClient + .Setup(c => c.List(It.IsAny())) + .ReturnsAsync(remoteResources.ToList()); + + var actualRes = await handler.FetchAsync( + "dir", + localResources, + dryRun: true + ); + + var resources = localResources + .Where(l => l.Resource.Id == "ID1") + .ToList(); + + foreach (var res in resources) + { + mockLoader + .Verify( + f => f.DeleteResource( + res, + It.IsAny()), + Times.Never); + + mockLoader + .Verify( + f => f.CreateOrUpdateResource( + res, + It.IsAny()), + Times.Never); + } + } + + [Test] + [TestCase(false, false)] + [TestCase(false, true)] + [TestCase(true, false)] + [TestCase(true, true)] + public async Task FetchAsync_CorrectResults(bool dryRun, bool reconcile) + { + // 1, 2, 3 + var localResources = GetLocalResources(); + // 3, 4, 5 + var remoteResources = GetRemoteResources(); + + Mock mockClient = new(); + Mock mockLoader = new(); + var handler = new CloudSaveFetchHandler(mockClient.Object, mockLoader.Object); + + mockClient + .Setup(c => c.List(It.IsAny())) + .ReturnsAsync(remoteResources.ToList()); + + var actualRes = await handler.FetchAsync( + "dir", + localResources, + dryRun: true, + reconcile: reconcile + ); + + + Assert.That(actualRes.Fetched, Has.Count.EqualTo(reconcile ? 5 : 3)); + + foreach (var localConfig in localResources) + { + Assert.That(actualRes.Fetched.ToList(), Does.Contain(localConfig)); + } + + StringAssert.StartsWith(Constants.Deleted, actualRes.Fetched[0].Status.MessageDetail); + StringAssert.StartsWith(Constants.Deleted, actualRes.Fetched[1].Status.MessageDetail); + StringAssert.StartsWith(Constants.Updated, actualRes.Fetched[2].Status.MessageDetail); + + if (reconcile) + { + StringAssert.StartsWith(Constants.Created, actualRes.Fetched[3].Status.MessageDetail); + StringAssert.StartsWith(Constants.Created, actualRes.Fetched[4].Status.MessageDetail); + } + + if (!dryRun) + { + foreach (var localConfig in actualRes.Fetched) + { + Assert.That(localConfig.Status.MessageSeverity, Is.EqualTo(SeverityLevel.Success)); + } + } + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudSave.UnitTest/Handlers/CreateCustomIndexHandlerTests.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudSave.UnitTest/Handlers/CreateCustomIndexHandlerTests.cs new file mode 100644 index 0000000..00b3cb3 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudSave.UnitTest/Handlers/CreateCustomIndexHandlerTests.cs @@ -0,0 +1,108 @@ +using Moq; +using NUnit.Framework; +using Unity.Services.Cli.CloudSave.Service; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using Spectre.Console; +using Unity.Services.Cli.Common.Console; +using Unity.Services.Cli.Common.Logging; +using Unity.Services.Cli.Common.Utils; +using Unity.Services.Cli.CloudSave.Handlers; +using Unity.Services.Cli.CloudSave.Input; +using Unity.Services.Cli.Common.Exceptions; +using Unity.Services.Gateway.CloudSaveApiV1.Generated.Model; +using Unity.Services.Cli.TestUtils; + +namespace Unity.Services.Cli.CloudSave.UnitTest.Handlers; + +public class CreateCustomIndexHandlerTests +{ + readonly Mock m_MockCloudSaveDataService = new(); + readonly Mock m_MockUnityEnvironment = new(); + readonly Mock m_MockLogger = new(); + + static readonly List k_ValidIndexFields = new List() + { + new IndexField("key1", true), + new IndexField("key2", false) + }; + + readonly CreateIndexBody m_ValidCreateCustomIndexBody = new CreateIndexBody( + new CreateIndexBodyIndexConfig(k_ValidIndexFields)); + + readonly CreateIndexResponse m_ValidCreateIndexResponse = new CreateIndexResponse("id", IndexStatus.READY); + + [SetUp] + public void SetUp() + { + m_MockUnityEnvironment.Reset(); + m_MockLogger.Reset(); + m_MockCloudSaveDataService.Reset(); + m_MockCloudSaveDataService.Setup(l => + l.CreateCustomIndexAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + CancellationToken.None)) + .Returns(Task.FromResult(m_ValidCreateIndexResponse)); + } + + [Test] + public async Task CreateCustomIndexHandler_CallsLoadingIndicator() + { + Mock mockLoadingIndicator = new Mock(); + + await CreateCustomIndexHandler.CreateCustomIndexAsync(null!, null!, null!, null!, mockLoadingIndicator.Object, CancellationToken.None); + + mockLoadingIndicator.Verify(ex => ex + .StartLoadingAsync(It.IsAny(), It.IsAny>()), Times.Once); + } + + [Test] + public void CreateCustomIndexHandler_HandlesInputAndLogsOnSuccess_UsingJsonBody() + { + var inputBody = JsonConvert.SerializeObject(m_ValidCreateCustomIndexBody); + var input = new CreateIndexInput() + { + Fields = null, + JsonFileOrBody = inputBody, + }; + Assert.DoesNotThrowAsync(async () => await CreateCustomIndexHandler.CreateCustomIndexAsync(input, m_MockUnityEnvironment.Object, m_MockCloudSaveDataService.Object, m_MockLogger.Object, default)); + TestsHelper.VerifyLoggerWasCalled(m_MockLogger, LogLevel.Critical, LoggerExtension.ResultEventId, Times.Once); + } + + [Test] + public void CreateCustomIndexHandler_HandlesInputAndLogsOnSuccess_UsingFields() + { + var inputFields = JsonConvert.SerializeObject(k_ValidIndexFields); + var input = new CreateIndexInput() + { + Fields = inputFields, + JsonFileOrBody = null, + }; + Assert.DoesNotThrowAsync(async () => await CreateCustomIndexHandler.CreateCustomIndexAsync(input, m_MockUnityEnvironment.Object, m_MockCloudSaveDataService.Object, m_MockLogger.Object, default)); + TestsHelper.VerifyLoggerWasCalled(m_MockLogger, LogLevel.Critical, LoggerExtension.ResultEventId, Times.Once); + } + + [Test] + public void CreateCustomIndexHandler_MissingBodyThrowsException() + { + var input = new CreateIndexInput(); + Assert.ThrowsAsync(async () => await CreateCustomIndexHandler.CreateCustomIndexAsync(input, m_MockUnityEnvironment.Object, m_MockCloudSaveDataService.Object, m_MockLogger.Object, default)); + } + + [Test] + public void CreateCustomIndexAsync_InvalidVisibilityThrowsException() + { + var inputFields = JsonConvert.SerializeObject(k_ValidIndexFields); + var input = new CreateIndexInput() + { + Fields = inputFields, + JsonFileOrBody = null, + Visibility = "InvalidVisibilityType", + }; + Assert.ThrowsAsync(async () => await CreateCustomIndexHandler.CreateCustomIndexAsync(input, m_MockUnityEnvironment.Object, m_MockCloudSaveDataService.Object, m_MockLogger.Object, default)); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudSave.UnitTest/Handlers/CreatePlayerIndexHandlerTests.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudSave.UnitTest/Handlers/CreatePlayerIndexHandlerTests.cs new file mode 100644 index 0000000..df318e2 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudSave.UnitTest/Handlers/CreatePlayerIndexHandlerTests.cs @@ -0,0 +1,111 @@ +using Moq; +using NUnit.Framework; +using Unity.Services.Cli.CloudSave.Service; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using Spectre.Console; +using Unity.Services.Cli.Common.Console; +using Unity.Services.Cli.Common.Logging; +using Unity.Services.Cli.Common.Utils; +using Unity.Services.Cli.CloudSave.Handlers; +using Unity.Services.Cli.CloudSave.Input; +using Unity.Services.Cli.CloudSave.Utils; +using Unity.Services.Cli.Common.Exceptions; +using Unity.Services.Gateway.CloudSaveApiV1.Generated.Model; +using Unity.Services.Cli.TestUtils; + +namespace Unity.Services.Cli.CloudSave.UnitTest.Handlers; + +public class CreatePlayerIndexHandlerTests +{ + readonly Mock m_MockCloudSaveDataService = new(); + readonly Mock m_MockUnityEnvironment = new(); + readonly Mock m_MockLogger = new(); + + static readonly List k_ValidIndexFields = new List() + { + new IndexField("key1", true), + new IndexField("key2", false) + }; + + readonly CreateIndexBody m_ValidCreatePlayerIndexBody = new CreateIndexBody( + new CreateIndexBodyIndexConfig(k_ValidIndexFields)); + + readonly CreateIndexResponse m_ValidCreateIndexResponse = new CreateIndexResponse("id", IndexStatus.READY); + + [SetUp] + public void SetUp() + { + m_MockUnityEnvironment.Reset(); + m_MockLogger.Reset(); + m_MockCloudSaveDataService.Reset(); + m_MockCloudSaveDataService.Setup(l => + l.CreatePlayerIndexAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + CancellationToken.None)) + .Returns(Task.FromResult(m_ValidCreateIndexResponse)); + } + + [Test] + public async Task CreatePlayerIndex_CallsLoadingIndicator() + { + Mock mockLoadingIndicator = new Mock(); + + await CreatePlayerIndexHandler.CreatePlayerIndexAsync(null!, null!, null!, null!, mockLoadingIndicator.Object, CancellationToken.None); + + mockLoadingIndicator.Verify(ex => ex + .StartLoadingAsync(It.IsAny(), It.IsAny>()), Times.Once); + } + + [Test] + public void CreatePlayerIndexHandler_HandlesInputAndLogsOnSuccess_UsingJsonBody() + { + var inputBody = JsonConvert.SerializeObject(m_ValidCreatePlayerIndexBody); + var input = new CreateIndexInput() + { + Fields = null, + JsonFileOrBody = inputBody, + Visibility = PlayerIndexVisibilityTypes.Default, + }; + Assert.DoesNotThrowAsync(async () => await CreatePlayerIndexHandler.CreatePlayerIndexAsync(input, m_MockUnityEnvironment.Object, m_MockCloudSaveDataService.Object, m_MockLogger.Object, default)); + TestsHelper.VerifyLoggerWasCalled(m_MockLogger, LogLevel.Critical, LoggerExtension.ResultEventId, Times.Once); + } + + [Test] + public void CreatePlayerIndexHandler_HandlesInputAndLogsOnSuccess_UsingFields() + { + var inputFields = JsonConvert.SerializeObject(k_ValidIndexFields); + var input = new CreateIndexInput() + { + Fields = inputFields, + JsonFileOrBody = null, + Visibility = PlayerIndexVisibilityTypes.Default, + }; + Assert.DoesNotThrowAsync(async () => await CreatePlayerIndexHandler.CreatePlayerIndexAsync(input, m_MockUnityEnvironment.Object, m_MockCloudSaveDataService.Object, m_MockLogger.Object, default)); + TestsHelper.VerifyLoggerWasCalled(m_MockLogger, LogLevel.Critical, LoggerExtension.ResultEventId, Times.Once); + } + + [Test] + public void CreatePlayerIndexHandler_MissingBodyThrowsException() + { + var input = new CreateIndexInput(); + Assert.ThrowsAsync(async () => await CreatePlayerIndexHandler.CreatePlayerIndexAsync(input, m_MockUnityEnvironment.Object, m_MockCloudSaveDataService.Object, m_MockLogger.Object, default)); + } + + [Test] + public void CreatePlayerIndexHandler_InvalidVisibilityThrowsException() + { + var inputFields = JsonConvert.SerializeObject(k_ValidIndexFields); + var input = new CreateIndexInput() + { + Fields = inputFields, + JsonFileOrBody = null, + Visibility = "InvalidVisibilityType", + }; + Assert.ThrowsAsync(async () => await CreatePlayerIndexHandler.CreatePlayerIndexAsync(input, m_MockUnityEnvironment.Object, m_MockCloudSaveDataService.Object, m_MockLogger.Object, default)); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudSave.UnitTest/Handlers/ListCustomIdsHandlerTests.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudSave.UnitTest/Handlers/ListCustomIdsHandlerTests.cs new file mode 100644 index 0000000..5fc1f44 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudSave.UnitTest/Handlers/ListCustomIdsHandlerTests.cs @@ -0,0 +1,107 @@ +using Moq; +using NUnit.Framework; +using Unity.Services.Cli.CloudSave.Service; +using Microsoft.Extensions.Logging; +using Spectre.Console; +using Unity.Services.Cli.Common.Console; +using Unity.Services.Cli.Common.Logging; +using Unity.Services.Cli.Common.Utils; +using Unity.Services.Cli.CloudSave.Handlers; +using Unity.Services.Cli.CloudSave.Input; +using Unity.Services.Cli.CloudSave.UnitTest.Utils; +using Unity.Services.Gateway.CloudSaveApiV1.Generated.Model; +using Unity.Services.Cli.TestUtils; + +namespace Unity.Services.Cli.CloudSave.UnitTest.Handlers; + +public class ListCustomIdsHandlerTests +{ + readonly Mock m_MockCloudSaveDataService = new(); + readonly Mock m_MockUnityEnvironment = new(); + readonly Mock m_MockLogger = new(); + + [SetUp] + public void SetUp() + { + m_MockUnityEnvironment.Reset(); + m_MockLogger.Reset(); + m_MockCloudSaveDataService.Reset(); + } + + [Test] + public async Task ListCustomDataIdsHandler_CallsLoadingIndicator() + { + Mock mockLoadingIndicator = new Mock(); + + await ListCustomDataIdsHandler.ListCustomDataIdsAsync(null!, null!, null!, null!, mockLoadingIndicator.Object, CancellationToken.None); + + mockLoadingIndicator.Verify(ex => ex + .StartLoadingAsync(It.IsAny(), It.IsAny>()), Times.Once); + } + + [Test] + public async Task ListCustomDataIdsHandler_CallsServiceAndLogger_WhenInputIsValid() + { + ListDataIdsInput input = new() + { + CloudProjectId = TestValues.ValidProjectId, + Start = "someStart", + Limit = 2 + }; + + m_MockUnityEnvironment.Setup(x => x.FetchIdentifierAsync(CancellationToken.None)) + .ReturnsAsync(TestValues.ValidEnvironmentId); + + var result = new GetCustomIdsResponse( + new List + { + new GetCustomIdsResponseResultsInner( + "testId1", + new AccessClassesWithMetadata + { + Private = new AccessClassMetadata + { + NumKeys = 1, + TotalSize = 100 + }, + Protected = new AccessClassMetadata + { + NumKeys = 2, + TotalSize = 200 + } + } + ), + new GetCustomIdsResponseResultsInner( + "testId2", + new AccessClassesWithMetadata + { + Default = new AccessClassMetadata + { + NumKeys = 3, + TotalSize = 300 + } + } + ), + }, + new GetPlayersWithDataResponseLinks("someLink") + ); + + m_MockCloudSaveDataService.Setup(x => x.ListCustomDataIdsAsync(TestValues.ValidProjectId, TestValues.ValidEnvironmentId, input.Start, input.Limit, CancellationToken.None)) + .ReturnsAsync(result); + + await ListCustomDataIdsHandler.ListCustomDataIdsAsync( + input, + m_MockUnityEnvironment.Object, + m_MockCloudSaveDataService!.Object, + m_MockLogger!.Object, + CancellationToken.None + ); + + m_MockUnityEnvironment.Verify(x => x.FetchIdentifierAsync(CancellationToken.None), Times.Once); + m_MockCloudSaveDataService.Verify(ex => ex.ListCustomDataIdsAsync(TestValues.ValidProjectId, TestValues.ValidEnvironmentId, + input.Start, input.Limit, CancellationToken.None), Times.Once); + + TestsHelper.VerifyLoggerWasCalled(m_MockLogger, LogLevel.Critical, LoggerExtension.ResultEventId, Times.Once); + } + +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudSave.UnitTest/Handlers/ListIndexesHandlerTests.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudSave.UnitTest/Handlers/ListIndexesHandlerTests.cs new file mode 100644 index 0000000..37e9243 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudSave.UnitTest/Handlers/ListIndexesHandlerTests.cs @@ -0,0 +1,97 @@ +using Moq; +using NUnit.Framework; +using Unity.Services.Cli.CloudSave.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.CloudSave.Handlers; +using Unity.Services.Cli.CloudSave.UnitTest.Utils; +using Unity.Services.Gateway.CloudSaveApiV1.Generated.Model; +using Unity.Services.Cli.TestUtils; + +namespace Unity.Services.Cli.CloudSave.UnitTest.Handlers; + +public class ListIndexesHandlerTests +{ + readonly Mock m_MockCloudSaveDataService = new(); + readonly Mock m_MockUnityEnvironment = new(); + readonly Mock m_MockLogger = new(); + + [SetUp] + public void SetUp() + { + m_MockUnityEnvironment.Reset(); + m_MockLogger.Reset(); + m_MockCloudSaveDataService.Reset(); + } + + [Test] + public async Task ListIndexes_CallsLoadingIndicator() + { + Mock mockLoadingIndicator = new Mock(); + + await ListIndexesHandler.ListIndexesAsync(null!, null!, null!, null!, mockLoadingIndicator.Object, CancellationToken.None); + + mockLoadingIndicator.Verify(ex => ex + .StartLoadingAsync(It.IsAny(), It.IsAny>()), Times.Once); + } + + [Test] + public async Task ListIndexesHandler_CallsServiceAndLogger_WhenInputIsValid() + { + CommonInput input = new() + { + CloudProjectId = TestValues.ValidProjectId, + }; + + + m_MockUnityEnvironment.Setup(x => x.FetchIdentifierAsync(CancellationToken.None)) + .ReturnsAsync(TestValues.ValidEnvironmentId); + + var result = new List + { + new LiveIndexConfigInner( + "testIndex1", + LiveIndexConfigInner.EntityTypeEnum.Player, + AccessClass.Default, + IndexStatus.READY, + new List() + { + new IndexField("testIndexKey1", true) + } + ), + new LiveIndexConfigInner( + "testIndex2", + LiveIndexConfigInner.EntityTypeEnum.Custom, + AccessClass.Private, + IndexStatus.BUILDING, + new List() + { + new IndexField("testIndexKey2", false) + } + ), + }; + + GetIndexIdsResponse response = new GetIndexIdsResponse(result); + m_MockCloudSaveDataService.Setup(x => x.ListIndexesAsync(TestValues.ValidProjectId, TestValues.ValidEnvironmentId, CancellationToken.None)) + .ReturnsAsync(response); + + await ListIndexesHandler.ListIndexesAsync( + input, + m_MockUnityEnvironment.Object, + m_MockCloudSaveDataService!.Object, + m_MockLogger!.Object, + CancellationToken.None + ); + + m_MockUnityEnvironment.Verify(x => x.FetchIdentifierAsync(CancellationToken.None), Times.Once); + m_MockCloudSaveDataService.Verify(ex => ex.ListIndexesAsync(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.CloudSave.UnitTest/Handlers/ListPlayerIdsHandlerTests.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudSave.UnitTest/Handlers/ListPlayerIdsHandlerTests.cs new file mode 100644 index 0000000..1ce379f --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudSave.UnitTest/Handlers/ListPlayerIdsHandlerTests.cs @@ -0,0 +1,106 @@ +using Moq; +using NUnit.Framework; +using Unity.Services.Cli.CloudSave.Service; +using Microsoft.Extensions.Logging; +using Spectre.Console; +using Unity.Services.Cli.Common.Console; +using Unity.Services.Cli.Common.Logging; +using Unity.Services.Cli.Common.Utils; +using Unity.Services.Cli.CloudSave.Handlers; +using Unity.Services.Cli.CloudSave.Input; +using Unity.Services.Cli.CloudSave.UnitTest.Utils; +using Unity.Services.Gateway.CloudSaveApiV1.Generated.Model; +using Unity.Services.Cli.TestUtils; + +namespace Unity.Services.Cli.CloudSave.UnitTest.Handlers; + +public class ListPlayerIdsHandlerTests +{ + readonly Mock m_MockCloudSaveDataService = new(); + readonly Mock m_MockUnityEnvironment = new(); + readonly Mock m_MockLogger = new(); + + [SetUp] + public void SetUp() + { + m_MockUnityEnvironment.Reset(); + m_MockLogger.Reset(); + m_MockCloudSaveDataService.Reset(); + } + + [Test] + public async Task ListPlayerDataIdsHandler_CallsLoadingIndicator() + { + Mock mockLoadingIndicator = new Mock(); + + await ListPlayerDataIdsHandler.ListPlayerDataIdsAsync(null!, null!, null!, null!, mockLoadingIndicator.Object, CancellationToken.None); + + mockLoadingIndicator.Verify(ex => ex + .StartLoadingAsync(It.IsAny(), It.IsAny>()), Times.Once); + } + + [Test] + public async Task ListPlayerDataIdsHandler_CallsServiceAndLogger_WhenInputIsValid() + { + ListDataIdsInput input = new() + { + CloudProjectId = TestValues.ValidProjectId, + Start = "someStart", + Limit = 2 + }; + + m_MockUnityEnvironment.Setup(x => x.FetchIdentifierAsync(CancellationToken.None)) + .ReturnsAsync(TestValues.ValidEnvironmentId); + + var result = new GetPlayersWithDataResponse( + new List + { + new GetPlayersWithDataResponseResultsInner( + "testId1", + new AccessClassesWithMetadata + { + Private = new AccessClassMetadata + { + NumKeys = 1, + TotalSize = 100 + }, + Protected = new AccessClassMetadata + { + NumKeys = 2, + TotalSize = 200 + } + } + ), + new GetPlayersWithDataResponseResultsInner( + "testId2", + new AccessClassesWithMetadata + { + Default = new AccessClassMetadata + { + NumKeys = 3, + TotalSize = 300 + } + } + ), + }, + new GetPlayersWithDataResponseLinks("someLink") + ); + + m_MockCloudSaveDataService.Setup(x => x.ListPlayerDataIdsAsync(TestValues.ValidProjectId, TestValues.ValidEnvironmentId, input.Start, input.Limit, CancellationToken.None)) + .ReturnsAsync(result); + + await ListPlayerDataIdsHandler.ListPlayerDataIdsAsync( + input, + m_MockUnityEnvironment.Object, + m_MockCloudSaveDataService!.Object, + m_MockLogger!.Object, + CancellationToken.None + ); + + m_MockUnityEnvironment.Verify(x => x.FetchIdentifierAsync(CancellationToken.None), Times.Once); + m_MockCloudSaveDataService.Verify(ex => ex.ListPlayerDataIdsAsync(TestValues.ValidProjectId, TestValues.ValidEnvironmentId, + input.Start, input.Limit, CancellationToken.None), Times.Once); + + TestsHelper.VerifyLoggerWasCalled(m_MockLogger, LogLevel.Critical, LoggerExtension.ResultEventId, Times.Once); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudSave.UnitTest/Handlers/QueryCustomDataHandlerTests.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudSave.UnitTest/Handlers/QueryCustomDataHandlerTests.cs new file mode 100644 index 0000000..74bbadf --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudSave.UnitTest/Handlers/QueryCustomDataHandlerTests.cs @@ -0,0 +1,86 @@ +using Moq; +using NUnit.Framework; +using Unity.Services.Cli.CloudSave.Service; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +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.CloudSave.Handlers; +using Unity.Services.Cli.CloudSave.Input; +using Unity.Services.Cli.CloudSave.UnitTest.Utils; +using Unity.Services.Cli.Common.Exceptions; +using Unity.Services.Gateway.CloudSaveApiV1.Generated.Model; +using Unity.Services.Cli.TestUtils; + +namespace Unity.Services.Cli.CloudSave.UnitTest.Handlers; + +public class QueryCustomDataHandlerTests +{ + readonly Mock m_MockCloudSaveDataService = new(); + readonly Mock m_MockUnityEnvironment = new(); + readonly Mock m_MockLogger = new(); + + readonly QueryIndexBody m_ValidQueryIndexBody = new QueryIndexBody( + new List() + { new FieldFilter ("fieldFilter_key","fieldFilter_value", FieldFilter.OpEnum.EQ, true)}, + new List { "returnKey1", "returnKey2" }, + 5, + 10); + + readonly List m_ValidQueryResponse = new List() + { + new QueryIndexResponseResultsInner("id", + new List() { + new Item("key1", "value", "writelock", new ModifiedMetadata(DateTime.Now), new ModifiedMetadata(DateTime.Today)) + } + ) + }; + + [SetUp] + public void SetUp() + { + m_MockUnityEnvironment.Reset(); + m_MockLogger.Reset(); + m_MockCloudSaveDataService.Reset(); + m_MockCloudSaveDataService.Setup(l => + l.QueryCustomDataAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + CancellationToken.None)) + .Returns(Task.FromResult(new QueryIndexResponse(m_ValidQueryResponse))); + } + [Test] + public async Task QueryDefaultCustomDataHandler_CallsLoadingIndicator() + { + Mock mockLoadingIndicator = new Mock(); + + await QueryCustomDataHandler.QueryCustomDataAsync(null!, null!, null!, null!, mockLoadingIndicator.Object, CancellationToken.None); + + mockLoadingIndicator.Verify(ex => ex + .StartLoadingAsync(It.IsAny(), It.IsAny>()), Times.Once); + } + + [Test] + public void QueryCustomDataHandler_HandlesInputAndLogsOnSuccess() + { + var inputBody = JsonConvert.SerializeObject(m_ValidQueryIndexBody); + var input = new QueryDataInput() + { + JsonFileOrBody = inputBody, + }; + Assert.DoesNotThrowAsync(async () => await QueryCustomDataHandler.QueryCustomDataAsync(input, m_MockUnityEnvironment.Object, m_MockCloudSaveDataService.Object, m_MockLogger.Object, default)); + TestsHelper.VerifyLoggerWasCalled(m_MockLogger, LogLevel.Critical, LoggerExtension.ResultEventId, Times.Once); + } + + [Test] + public void QueryCustomDataHandler_MissingBodyThrowsException() + { + var input = new QueryDataInput(); + Assert.ThrowsAsync(async () => await QueryCustomDataHandler.QueryCustomDataAsync(input, m_MockUnityEnvironment.Object, m_MockCloudSaveDataService.Object, m_MockLogger.Object, default)); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudSave.UnitTest/Handlers/QueryPlayerDataHandlerTests.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudSave.UnitTest/Handlers/QueryPlayerDataHandlerTests.cs new file mode 100644 index 0000000..e3bf818 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudSave.UnitTest/Handlers/QueryPlayerDataHandlerTests.cs @@ -0,0 +1,85 @@ +using Moq; +using NUnit.Framework; +using Unity.Services.Cli.CloudSave.Service; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using Spectre.Console; +using Unity.Services.Cli.Common.Console; +using Unity.Services.Cli.Common.Logging; +using Unity.Services.Cli.Common.Utils; +using Unity.Services.Cli.CloudSave.Handlers; +using Unity.Services.Cli.CloudSave.Input; +using Unity.Services.Cli.Common.Exceptions; +using Unity.Services.Gateway.CloudSaveApiV1.Generated.Model; +using Unity.Services.Cli.TestUtils; + +namespace Unity.Services.Cli.CloudSave.UnitTest.Handlers; + +public class QueryPlayerDataHandlerTests +{ + readonly Mock m_MockCloudSaveDataService = new(); + readonly Mock m_MockUnityEnvironment = new(); + readonly Mock m_MockLogger = new(); + + readonly QueryIndexBody m_ValidQueryIndexBody = new QueryIndexBody( + new List() + { new FieldFilter ("fieldFilter_key","fieldFilter_value", FieldFilter.OpEnum.EQ, true)}, + new List { "returnKey1", "returnKey2" }, + 5, + 10); + + readonly List m_ValidQueryResponse = new List() + { + new QueryIndexResponseResultsInner("id", + new List() { + new Item("key1", "value", "writelock", new ModifiedMetadata(DateTime.Now), new ModifiedMetadata(DateTime.Today)) + } + ) + }; + + [SetUp] + public void SetUp() + { + m_MockUnityEnvironment.Reset(); + m_MockLogger.Reset(); + m_MockCloudSaveDataService.Reset(); + m_MockCloudSaveDataService.Setup(l => + l.QueryPlayerDataAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + CancellationToken.None)) + .Returns(Task.FromResult(new QueryIndexResponse(m_ValidQueryResponse))); + } + + [Test] + public async Task QueryPlayerData_CallsLoadingIndicator() + { + Mock mockLoadingIndicator = new Mock(); + + await QueryPlayerDataHandler.QueryPlayerDataAsync(null!, null!, null!, null!, mockLoadingIndicator.Object, CancellationToken.None); + + mockLoadingIndicator.Verify(ex => ex + .StartLoadingAsync(It.IsAny(), It.IsAny>()), Times.Once); + } + + [Test] + public void QueryPlayerDataHandler_HandlesInputAndLogsOnSuccess() + { + var inputBody = JsonConvert.SerializeObject(m_ValidQueryIndexBody); + var input = new QueryDataInput() + { + JsonFileOrBody = inputBody, + }; + Assert.DoesNotThrowAsync(async () => await QueryPlayerDataHandler.QueryPlayerDataAsync(input, m_MockUnityEnvironment.Object, m_MockCloudSaveDataService.Object, m_MockLogger.Object, default)); + TestsHelper.VerifyLoggerWasCalled(m_MockLogger, LogLevel.Critical, LoggerExtension.ResultEventId, Times.Once); + } + + [Test] + public void QueryPlayerDataHandler_MissingBodyThrowsException() + { + var input = new QueryDataInput(); + Assert.ThrowsAsync(async () => await QueryPlayerDataHandler.QueryPlayerDataAsync(input, m_MockUnityEnvironment.Object, m_MockCloudSaveDataService.Object, m_MockLogger.Object, default)); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudSave.UnitTest/Service/CloudSaveDataServiceTests.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudSave.UnitTest/Service/CloudSaveDataServiceTests.cs new file mode 100644 index 0000000..f61e9c0 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudSave.UnitTest/Service/CloudSaveDataServiceTests.cs @@ -0,0 +1,952 @@ +using Moq; +using Newtonsoft.Json; +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.ServiceAccountAuthentication; +using Unity.Services.Cli.ServiceAccountAuthentication.Token; +using Unity.Services.Cli.CloudSave.Service; +using Unity.Services.Cli.CloudSave.UnitTest.Utils; +using Unity.Services.Cli.CloudSave.Utils; +using Unity.Services.Gateway.CloudSaveApiV1.Generated.Api; +using Unity.Services.Gateway.CloudSaveApiV1.Generated.Model; + +namespace Unity.Services.Cli.CloudSave.UnitTest.Service; + +[TestFixture] +class CloudSaveServiceTests +{ + 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 Mock m_DataApiAsyncMock = new(); + + CloudSaveDataService? m_CloudSaveDataService; + + readonly QueryIndexBody m_ValidQueryIndexBody = new QueryIndexBody( + new List() + { new FieldFilter ("fieldFilter_key","fieldFilter_value", FieldFilter.OpEnum.EQ, true)}, + new List { "returnKey1", "returnKey2" }, + 5, + 10); + + readonly List m_ValidQueryResponse = new List() + { + new QueryIndexResponseResultsInner("id", + new List() { + new Item("key1", "value", "writelock", new ModifiedMetadata(DateTime.Now), new ModifiedMetadata(DateTime.Today)) + } + ) + }; + + static readonly List k_ValidIndexFields = new List() + { + new IndexField("key1", true), + new IndexField("key2", false) + }; + + readonly CreateIndexBody m_ValidCreateIndexBody = new CreateIndexBody( + new CreateIndexBodyIndexConfig(k_ValidIndexFields)); + + readonly CreateIndexResponse m_ValidCreateIndexResponse = new CreateIndexResponse("id", IndexStatus.READY); + + [SetUp] + public void SetUp() + { + m_ValidatorObject.Reset(); + m_AuthenticationServiceObject.Reset(); + m_AuthenticationServiceObject.Setup(a => a.GetAccessTokenAsync(CancellationToken.None)) + .Returns(Task.FromResult(k_TestAccessToken)); + + m_DataApiAsyncMock.Reset(); + m_DataApiAsyncMock.Setup(a => a.Configuration) + .Returns(new Gateway.CloudSaveApiV1.Generated.Client.Configuration()); + + m_CloudSaveDataService = new CloudSaveDataService( + m_DataApiAsyncMock.Object, + m_ValidatorObject.Object, + m_AuthenticationServiceObject.Object); + } + + [Test] + public async Task AuthorizeCloudSaveService() + { + await m_CloudSaveDataService!.AuthorizeServiceAsync(CancellationToken.None); + m_AuthenticationServiceObject.Verify(a => a.GetAccessTokenAsync(CancellationToken.None)); + Assert.That( + m_DataApiAsyncMock.Object.Configuration.DefaultHeaders[ + AccessTokenHelper.HeaderKey], Is.EqualTo(k_TestAccessToken.ToHeaderValue())); + } + + [Test] + public void InvalidProjectIdThrowConfigValidationException() + { + m_ValidatorObject.Setup(v => v.ThrowExceptionIfConfigInvalid(Keys.ConfigKeys.ProjectId, k_InvalidProjectId)) + .Throws(new ConfigValidationException(Keys.ConfigKeys.EnvironmentId, k_InvalidEnvironmentId, It.IsAny())); + Assert.Throws( + () => m_CloudSaveDataService!.ValidateProjectIdAndEnvironmentId( + k_InvalidProjectId, TestValues.ValidEnvironmentId)); + } + + [Test] + public void InvalidEnvironmentIdThrowConfigValidationException() + { + m_ValidatorObject.Setup(v => v.ThrowExceptionIfConfigInvalid(Keys.ConfigKeys.EnvironmentId, k_InvalidEnvironmentId)) + .Throws(new ConfigValidationException(Keys.ConfigKeys.EnvironmentId, k_InvalidEnvironmentId, It.IsAny())); + Assert.Throws( + () => m_CloudSaveDataService!.ValidateProjectIdAndEnvironmentId( + TestValues.ValidProjectId, k_InvalidEnvironmentId)); + } + + [Test] + public async Task ListIndexesAsync_Success() + { + string mockErrorMsg; + m_ValidatorObject.Setup(v => v.IsConfigValid(It.IsAny(), It.IsAny(), out mockErrorMsg)) + .Returns(true); + + // Setup ListIndexes response + var result = new List + { + new LiveIndexConfigInner( + "testIndex1", + LiveIndexConfigInner.EntityTypeEnum.Player, + AccessClass.Default, + IndexStatus.READY, + new List() + { + new IndexField("testIndexKey1", true) + } + ), + new LiveIndexConfigInner( + "testIndex2", + LiveIndexConfigInner.EntityTypeEnum.Custom, + AccessClass.Private, + IndexStatus.BUILDING, + new List() + { + new IndexField("testIndexKey2", false) + } + ), + }; + m_DataApiAsyncMock.Setup( + t => t.ListIndexesAsync( + It.Is(id => id.ToString() == TestValues.ValidProjectId), + It.Is(id => id.ToString() == TestValues.ValidEnvironmentId), + It.IsAny(), + CancellationToken.None)).ReturnsAsync(new GetIndexIdsResponse(result)); + + var actual = await m_CloudSaveDataService!.ListIndexesAsync( + TestValues.ValidProjectId, TestValues.ValidEnvironmentId, CancellationToken.None); + + m_DataApiAsyncMock.VerifyAll(); + Assert.That(actual.Indexes, Has.Count.EqualTo(2)); + } + + [Test] + public async Task ListCustomIdsAsync_Success() + { + string mockErrorMsg; + m_ValidatorObject.Setup(v => v.IsConfigValid(It.IsAny(), It.IsAny(), out mockErrorMsg)) + .Returns(true); + + // Setup ListCustomIds response + var result = new GetCustomIdsResponse( + new List + { + new GetCustomIdsResponseResultsInner( + "testId1", + new AccessClassesWithMetadata + { + Private = new AccessClassMetadata + { + NumKeys = 1, + TotalSize = 100 + }, + Protected = new AccessClassMetadata + { + NumKeys = 2, + TotalSize = 200 + } + } + ), + new GetCustomIdsResponseResultsInner( + "testId2", + new AccessClassesWithMetadata + { + Default = new AccessClassMetadata + { + NumKeys = 3, + TotalSize = 300 + } + } + ), + }, + new GetPlayersWithDataResponseLinks("someLink") + ); + m_DataApiAsyncMock.Setup( + t => t.ListCustomDataIDsAsync( + It.Is(id => id.ToString() == TestValues.ValidProjectId), + It.Is(id => id.ToString() == TestValues.ValidEnvironmentId), + It.IsAny(), + It.IsAny(), + It.IsAny(), + CancellationToken.None)).ReturnsAsync(result); + + var actual = await m_CloudSaveDataService!.ListCustomDataIdsAsync( + TestValues.ValidProjectId, TestValues.ValidEnvironmentId, "someStart", 2, CancellationToken.None); + + m_DataApiAsyncMock.VerifyAll(); + Assert.Multiple(() => + { + Assert.That(actual.Results, Has.Count.EqualTo(2)); + Assert.That(result.Links.Next, Is.EqualTo(actual.Links.Next)); + }); + } + + [Test] + public async Task ListPlayerIdsAsync_Success() + { + string mockErrorMsg; + m_ValidatorObject.Setup(v => v.IsConfigValid(It.IsAny(), It.IsAny(), out mockErrorMsg)) + .Returns(true); + + // Setup ListCustomIds response + var result = new GetPlayersWithDataResponse( + new List + { + new GetPlayersWithDataResponseResultsInner( + "testId1", + new AccessClassesWithMetadata + { + Private = new AccessClassMetadata + { + NumKeys = 1, + TotalSize = 100 + }, + Protected = new AccessClassMetadata + { + NumKeys = 2, + TotalSize = 200 + } + } + ), + new GetPlayersWithDataResponseResultsInner( + "testId2", + new AccessClassesWithMetadata + { + Default = new AccessClassMetadata + { + NumKeys = 3, + TotalSize = 300 + } + } + ), + }, + new GetPlayersWithDataResponseLinks("someLink") + ); + m_DataApiAsyncMock.Setup( + t => t.GetPlayersWithItemsAsync( + It.Is(id => id.ToString() == TestValues.ValidProjectId), + It.Is(id => id.ToString() == TestValues.ValidEnvironmentId), + It.IsAny(), + It.IsAny(), + It.IsAny(), + CancellationToken.None)).ReturnsAsync(result); + + var actual = await m_CloudSaveDataService!.ListPlayerDataIdsAsync( + TestValues.ValidProjectId, TestValues.ValidEnvironmentId, "someStart", 2, CancellationToken.None); + + m_DataApiAsyncMock.VerifyAll(); + Assert.Multiple(() => + { + Assert.That(actual.Results, Has.Count.EqualTo(2)); + Assert.That(result.Links.Next, Is.EqualTo(actual.Links.Next)); + }); + } + + [Test] + public async Task QueryPlayerData_Default_Success() + { + string mockErrorMsg; + m_ValidatorObject.Setup(v => v.IsConfigValid(It.IsAny(), It.IsAny(), out mockErrorMsg)) + .Returns(true); + + m_DataApiAsyncMock.Setup( + t => t.QueryDefaultPlayerDataAsync( + It.Is(id => id.ToString() == TestValues.ValidProjectId), + It.Is(id => id.ToString() == TestValues.ValidEnvironmentId), + It.IsAny(), + It.IsAny(), + CancellationToken.None)).ReturnsAsync(new QueryIndexResponse(m_ValidQueryResponse)); + + var body = JsonConvert.SerializeObject(m_ValidQueryIndexBody); + var visibility = PlayerIndexVisibilityTypes.Default; + + var actual = await m_CloudSaveDataService!.QueryPlayerDataAsync( + TestValues.ValidProjectId, TestValues.ValidEnvironmentId, visibility, body, CancellationToken.None); + + m_DataApiAsyncMock.VerifyAll(); + Assert.That(actual.Results, Has.Count.EqualTo(1)); + } + + [Test] + public async Task QueryPlayerData_Default_FailsWithInvalidBody() + { + string mockErrorMsg; + m_ValidatorObject.Setup(v => v.IsConfigValid(It.IsAny(), It.IsAny(), out mockErrorMsg)) + .Returns(true); + + var body = "somebadjson"; + var visibility = PlayerIndexVisibilityTypes.Public; + + try + { + var actual = await m_CloudSaveDataService!.QueryPlayerDataAsync( + TestValues.ValidProjectId, + TestValues.ValidEnvironmentId, + visibility, + body, + CancellationToken.None); + Assert.Fail(); // Should not get this far + } + catch (CliException e) + { + Assert.That(e.Message, Does.Contain("Failed to deserialize object for Cloud Save request.")); + } + } + + [Test] + public async Task QueryPlayerData_Protected_Success() + { + string mockErrorMsg; + m_ValidatorObject.Setup(v => v.IsConfigValid(It.IsAny(), It.IsAny(), out mockErrorMsg)) + .Returns(true); + + m_DataApiAsyncMock.Setup( + t => t.QueryProtectedPlayerDataAsync( + It.Is(id => id.ToString() == TestValues.ValidProjectId), + It.Is(id => id.ToString() == TestValues.ValidEnvironmentId), + It.IsAny(), + It.IsAny(), + CancellationToken.None)).ReturnsAsync(new QueryIndexResponse(m_ValidQueryResponse)); + + var body = JsonConvert.SerializeObject(m_ValidQueryIndexBody); + var visibility = PlayerIndexVisibilityTypes.Protected; + + var actual = await m_CloudSaveDataService!.QueryPlayerDataAsync( + TestValues.ValidProjectId, TestValues.ValidEnvironmentId, visibility, body, CancellationToken.None); + + m_DataApiAsyncMock.VerifyAll(); + Assert.That(actual.Results, Has.Count.EqualTo(1)); + } + + [Test] + public async Task QueryPlayerData_Protected_FailsWithInvalidBody() + { + string mockErrorMsg; + m_ValidatorObject.Setup(v => v.IsConfigValid(It.IsAny(), It.IsAny(), out mockErrorMsg)) + .Returns(true); + + var body = "somebadjson"; + var visibility = PlayerIndexVisibilityTypes.Public; + + try + { + var actual = await m_CloudSaveDataService!.QueryPlayerDataAsync( + TestValues.ValidProjectId, + TestValues.ValidEnvironmentId, + visibility, + body, + CancellationToken.None); + Assert.Fail(); // Should not get this far + } + catch (CliException e) + { + Assert.That(e.Message, Does.Contain("Failed to deserialize object for Cloud Save request.")); + } + } + + [Test] + public async Task QueryPlayerData_Public_Success() + { + string mockErrorMsg; + m_ValidatorObject.Setup(v => v.IsConfigValid(It.IsAny(), It.IsAny(), out mockErrorMsg)) + .Returns(true); + + m_DataApiAsyncMock.Setup( + t => t.QueryPublicPlayerDataAsync( + It.Is(id => id.ToString() == TestValues.ValidProjectId), + It.Is(id => id.ToString() == TestValues.ValidEnvironmentId), + It.IsAny(), + It.IsAny(), + CancellationToken.None)).ReturnsAsync(new QueryIndexResponse(m_ValidQueryResponse)); + + var body = JsonConvert.SerializeObject(m_ValidQueryIndexBody); + var visibility = PlayerIndexVisibilityTypes.Public; + + var actual = await m_CloudSaveDataService!.QueryPlayerDataAsync( + TestValues.ValidProjectId, TestValues.ValidEnvironmentId, visibility, body, CancellationToken.None); + + m_DataApiAsyncMock.VerifyAll(); + Assert.That(actual.Results, Has.Count.EqualTo(1)); + } + + [Test] + public async Task QueryPlayerData_Public_FailsWithInvalidBody() + { + string mockErrorMsg; + m_ValidatorObject.Setup(v => v.IsConfigValid(It.IsAny(), It.IsAny(), out mockErrorMsg)) + .Returns(true); + + var body = "somebadjson"; + var visibility = PlayerIndexVisibilityTypes.Public; + try + { + var actual = await m_CloudSaveDataService!.QueryPlayerDataAsync( + TestValues.ValidProjectId, + TestValues.ValidEnvironmentId, + visibility, + body, + CancellationToken.None); + Assert.Fail(); // Should not get this far + } + catch (CliException e) + { + Assert.That(e.Message, Does.Contain("Failed to deserialize object for Cloud Save request.")); + } + } + + [Test] + public async Task QueryCustomData_Default_Success() + { + string mockErrorMsg; + m_ValidatorObject.Setup(v => v.IsConfigValid(It.IsAny(), It.IsAny(), out mockErrorMsg)) + .Returns(true); + + m_DataApiAsyncMock.Setup( + t => t.QueryDefaultCustomDataAsync( + It.Is(id => id.ToString() == TestValues.ValidProjectId), + It.Is(id => id.ToString() == TestValues.ValidEnvironmentId), + It.IsAny(), + It.IsAny(), + CancellationToken.None)).ReturnsAsync(new QueryIndexResponse(m_ValidQueryResponse)); + + var body = JsonConvert.SerializeObject(m_ValidQueryIndexBody); + var visibility = CustomIndexVisibilityTypes.Default; + + var actual = await m_CloudSaveDataService!.QueryCustomDataAsync( + TestValues.ValidProjectId, TestValues.ValidEnvironmentId, visibility, body, CancellationToken.None); + + m_DataApiAsyncMock.VerifyAll(); + Assert.That(actual.Results, Has.Count.EqualTo(1)); + } + + [Test] + public async Task QueryCustomData_Default_FailsWithInvalidBody() + { + string mockErrorMsg; + m_ValidatorObject.Setup(v => v.IsConfigValid(It.IsAny(), It.IsAny(), out mockErrorMsg)) + .Returns(true); + + var body = "somebadjson"; + var visibility = CustomIndexVisibilityTypes.Default; + + try + { + var actual = await m_CloudSaveDataService!.QueryCustomDataAsync( + TestValues.ValidProjectId, + TestValues.ValidEnvironmentId, + visibility, + body, + CancellationToken.None); + Assert.Fail(); // Should not get this far + } + catch (CliException e) + { + Assert.That(e.Message, Does.Contain("Failed to deserialize object for Cloud Save request.")); + } + } + + [Test] + public async Task QueryCustomData_Private_Success() + { + string mockErrorMsg; + m_ValidatorObject.Setup(v => v.IsConfigValid(It.IsAny(), It.IsAny(), out mockErrorMsg)) + .Returns(true); + + m_DataApiAsyncMock.Setup( + t => t.QueryPrivateCustomDataAsync( + It.Is(id => id.ToString() == TestValues.ValidProjectId), + It.Is(id => id.ToString() == TestValues.ValidEnvironmentId), + It.IsAny(), + It.IsAny(), + CancellationToken.None)).ReturnsAsync(new QueryIndexResponse(m_ValidQueryResponse)); + + var body = JsonConvert.SerializeObject(m_ValidQueryIndexBody); + var visibility = CustomIndexVisibilityTypes.Private; + + var actual = await m_CloudSaveDataService!.QueryCustomDataAsync( + TestValues.ValidProjectId, TestValues.ValidEnvironmentId, visibility, body, CancellationToken.None); + + m_DataApiAsyncMock.VerifyAll(); + Assert.That(actual.Results, Has.Count.EqualTo(1)); + } + + [Test] + public async Task QueryCustomData_Private_FailsWithInvalidBody() + { + string mockErrorMsg; + m_ValidatorObject.Setup(v => v.IsConfigValid(It.IsAny(), It.IsAny(), out mockErrorMsg)) + .Returns(true); + + var body = "somebadjson"; + var visibility = CustomIndexVisibilityTypes.Private; + + try + { + var actual = await m_CloudSaveDataService!.QueryCustomDataAsync( + TestValues.ValidProjectId, + TestValues.ValidEnvironmentId, + visibility, + body, + CancellationToken.None); + Assert.Fail(); // Should not get this far + } + catch (CliException e) + { + Assert.That(e.Message, Does.Contain("Failed to deserialize object for Cloud Save request.")); + } + } + + [Test] + public async Task CreatePlayerIndex_Default_SerializedBody_Success() + { + string mockErrorMsg; + m_ValidatorObject.Setup(v => v.IsConfigValid(It.IsAny(), It.IsAny(), out mockErrorMsg)) + .Returns(true); + + m_DataApiAsyncMock.Setup( + t => t.CreateDefaultPlayerIndexAsync( + It.Is(id => id.ToString() == TestValues.ValidProjectId), + It.Is(id => id.ToString() == TestValues.ValidEnvironmentId), + It.IsAny(), + It.IsAny(), + CancellationToken.None)).ReturnsAsync(m_ValidCreateIndexResponse); + + var body = JsonConvert.SerializeObject(m_ValidCreateIndexBody); + var visibility = PlayerIndexVisibilityTypes.Default; + + var actual = await m_CloudSaveDataService!.CreatePlayerIndexAsync( + TestValues.ValidProjectId, TestValues.ValidEnvironmentId, null, visibility, body, CancellationToken.None); + + m_DataApiAsyncMock.VerifyAll(); + Assert.Multiple(() => + { + Assert.That(actual.Id, Is.EqualTo(m_ValidCreateIndexResponse.Id)); + Assert.That(actual.Status, Is.EqualTo(m_ValidCreateIndexResponse.Status)); + }); + } + + [Test] + public async Task CreatePlayerIndex_Default_SerializedFields_Success() + { + string mockErrorMsg; + m_ValidatorObject.Setup(v => v.IsConfigValid(It.IsAny(), It.IsAny(), out mockErrorMsg)) + .Returns(true); + + m_DataApiAsyncMock.Setup( + t => t.CreateDefaultPlayerIndexAsync( + It.Is(id => id.ToString() == TestValues.ValidProjectId), + It.Is(id => id.ToString() == TestValues.ValidEnvironmentId), + It.IsAny(), + It.IsAny(), + CancellationToken.None)).ReturnsAsync(m_ValidCreateIndexResponse); + + var fields = JsonConvert.SerializeObject(k_ValidIndexFields); + var visibility = PlayerIndexVisibilityTypes.Default; + + var actual = await m_CloudSaveDataService!.CreatePlayerIndexAsync( + TestValues.ValidProjectId, TestValues.ValidEnvironmentId, fields, visibility, null, CancellationToken.None); + + m_DataApiAsyncMock.VerifyAll(); + Assert.Multiple(() => + { + Assert.That(actual.Id, Is.EqualTo(m_ValidCreateIndexResponse.Id)); + Assert.That(actual.Status, Is.EqualTo(m_ValidCreateIndexResponse.Status)); + }); + } + + [Test] + public async Task CreatePlayerIndex_Public_SerializedBody_Success() + { + string mockErrorMsg; + m_ValidatorObject.Setup(v => v.IsConfigValid(It.IsAny(), It.IsAny(), out mockErrorMsg)) + .Returns(true); + + m_DataApiAsyncMock.Setup( + t => t.CreatePublicPlayerIndexAsync( + It.Is(id => id.ToString() == TestValues.ValidProjectId), + It.Is(id => id.ToString() == TestValues.ValidEnvironmentId), + It.IsAny(), + It.IsAny(), + CancellationToken.None)).ReturnsAsync(m_ValidCreateIndexResponse); + + var body = JsonConvert.SerializeObject(m_ValidCreateIndexBody); + var visibility = PlayerIndexVisibilityTypes.Public; + + var actual = await m_CloudSaveDataService!.CreatePlayerIndexAsync( + TestValues.ValidProjectId, TestValues.ValidEnvironmentId, null, visibility, body, CancellationToken.None); + + m_DataApiAsyncMock.VerifyAll(); + Assert.Multiple(() => + { + Assert.That(actual.Id, Is.EqualTo(m_ValidCreateIndexResponse.Id)); + Assert.That(actual.Status, Is.EqualTo(m_ValidCreateIndexResponse.Status)); + }); + } + + [Test] + public async Task CreatePlayerIndex_Public_SerializedFields_Success() + { + string mockErrorMsg; + m_ValidatorObject.Setup(v => v.IsConfigValid(It.IsAny(), It.IsAny(), out mockErrorMsg)) + .Returns(true); + + m_DataApiAsyncMock.Setup( + t => t.CreatePublicPlayerIndexAsync( + It.Is(id => id.ToString() == TestValues.ValidProjectId), + It.Is(id => id.ToString() == TestValues.ValidEnvironmentId), + It.IsAny(), + It.IsAny(), + CancellationToken.None)).ReturnsAsync(m_ValidCreateIndexResponse); + + var fields = JsonConvert.SerializeObject(k_ValidIndexFields); + var visibility = PlayerIndexVisibilityTypes.Public; + + var actual = await m_CloudSaveDataService!.CreatePlayerIndexAsync( + TestValues.ValidProjectId, TestValues.ValidEnvironmentId, fields, visibility, null, CancellationToken.None); + + m_DataApiAsyncMock.VerifyAll(); + Assert.Multiple(() => + { + Assert.That(actual.Id, Is.EqualTo(m_ValidCreateIndexResponse.Id)); + Assert.That(actual.Status, Is.EqualTo(m_ValidCreateIndexResponse.Status)); + }); + } + + [Test] + public async Task CreatePlayerIndex_Protected_SerializedBody_Success() + { + string mockErrorMsg; + m_ValidatorObject.Setup(v => v.IsConfigValid(It.IsAny(), It.IsAny(), out mockErrorMsg)) + .Returns(true); + + m_DataApiAsyncMock.Setup( + t => t.CreateProtectedPlayerIndexAsync( + It.Is(id => id.ToString() == TestValues.ValidProjectId), + It.Is(id => id.ToString() == TestValues.ValidEnvironmentId), + It.IsAny(), + It.IsAny(), + CancellationToken.None)).ReturnsAsync(m_ValidCreateIndexResponse); + + var body = JsonConvert.SerializeObject(m_ValidCreateIndexBody); + var visibility = PlayerIndexVisibilityTypes.Protected; + + var actual = await m_CloudSaveDataService!.CreatePlayerIndexAsync( + TestValues.ValidProjectId, TestValues.ValidEnvironmentId, null, visibility, body, CancellationToken.None); + + m_DataApiAsyncMock.VerifyAll(); + Assert.Multiple(() => + { + Assert.That(actual.Id, Is.EqualTo(m_ValidCreateIndexResponse.Id)); + Assert.That(actual.Status, Is.EqualTo(m_ValidCreateIndexResponse.Status)); + }); + } + + [Test] + public async Task CreatePlayerIndex_Protected_SerializedFields_Success() + { + string mockErrorMsg; + m_ValidatorObject.Setup(v => v.IsConfigValid(It.IsAny(), It.IsAny(), out mockErrorMsg)) + .Returns(true); + + m_DataApiAsyncMock.Setup( + t => t.CreateProtectedPlayerIndexAsync( + It.Is(id => id.ToString() == TestValues.ValidProjectId), + It.Is(id => id.ToString() == TestValues.ValidEnvironmentId), + It.IsAny(), + It.IsAny(), + CancellationToken.None)).ReturnsAsync(m_ValidCreateIndexResponse); + + var fields = JsonConvert.SerializeObject(k_ValidIndexFields); + var visibility = PlayerIndexVisibilityTypes.Protected; + + var actual = await m_CloudSaveDataService!.CreatePlayerIndexAsync( + TestValues.ValidProjectId, TestValues.ValidEnvironmentId, fields, visibility, null, CancellationToken.None); + + m_DataApiAsyncMock.VerifyAll(); + Assert.Multiple(() => + { + Assert.That(actual.Id, Is.EqualTo(m_ValidCreateIndexResponse.Id)); + Assert.That(actual.Status, Is.EqualTo(m_ValidCreateIndexResponse.Status)); + }); + } + + [Test] + public async Task CreatePlayerIndex_FailsWithInvalidBody() + { + string mockErrorMsg; + m_ValidatorObject.Setup(v => v.IsConfigValid(It.IsAny(), It.IsAny(), out mockErrorMsg)) + .Returns(true); + + var body = "somebadjson"; + var visibility = PlayerIndexVisibilityTypes.Default; + + try + { + var actual = await m_CloudSaveDataService!.CreatePlayerIndexAsync( + TestValues.ValidProjectId, + TestValues.ValidEnvironmentId, + null, + visibility, + body, + CancellationToken.None); + Assert.Fail(); // Should not get this far + } + catch (CliException e) + { + Assert.That(e.Message, Does.Contain("Failed to deserialize object for Cloud Save request.")); + } + } + + [Test] + public async Task CreatePlayerIndex_FailsWithBothBodyAndFieldsSpecified() + { + string mockErrorMsg; + m_ValidatorObject.Setup(v => v.IsConfigValid(It.IsAny(), It.IsAny(), out mockErrorMsg)) + .Returns(true); + + var body = JsonConvert.SerializeObject(m_ValidCreateIndexBody); + var fields = JsonConvert.SerializeObject(k_ValidIndexFields); + var visibility = CustomIndexVisibilityTypes.Default; + + try + { + var actual = await m_CloudSaveDataService!.CreatePlayerIndexAsync( + TestValues.ValidProjectId, + TestValues.ValidEnvironmentId, + fields, + visibility, + body, + CancellationToken.None); + Assert.Fail(); // Should not get this far + } + catch (CliException e) + { + Assert.That(e.Message, Does.Contain("Index body and fields cannot both be specified.")); + } + } + + [Test] + public async Task CreateCustomIndex_Default_SerializedBody_Success() + { + string mockErrorMsg; + m_ValidatorObject.Setup(v => v.IsConfigValid(It.IsAny(), It.IsAny(), out mockErrorMsg)) + .Returns(true); + + m_DataApiAsyncMock.Setup( + t => t.CreateDefaultCustomIndexAsync( + It.Is(id => id.ToString() == TestValues.ValidProjectId), + It.Is(id => id.ToString() == TestValues.ValidEnvironmentId), + It.IsAny(), + It.IsAny(), + CancellationToken.None)).ReturnsAsync(m_ValidCreateIndexResponse); + + var body = JsonConvert.SerializeObject(m_ValidCreateIndexBody); + var visibility = CustomIndexVisibilityTypes.Default; + var actual = await m_CloudSaveDataService!.CreateCustomIndexAsync( + TestValues.ValidProjectId, TestValues.ValidEnvironmentId, null, visibility, body, CancellationToken.None); + + m_DataApiAsyncMock.VerifyAll(); + Assert.Multiple(() => + { + Assert.That(actual.Id, Is.EqualTo(m_ValidCreateIndexResponse.Id)); + Assert.That(actual.Status, Is.EqualTo(m_ValidCreateIndexResponse.Status)); + }); + } + + [Test] + public async Task CreateCustomIndex_Default_SerializedFields_Success() + { + string mockErrorMsg; + m_ValidatorObject.Setup(v => v.IsConfigValid(It.IsAny(), It.IsAny(), out mockErrorMsg)) + .Returns(true); + + m_DataApiAsyncMock.Setup( + t => t.CreateDefaultCustomIndexAsync( + It.Is(id => id.ToString() == TestValues.ValidProjectId), + It.Is(id => id.ToString() == TestValues.ValidEnvironmentId), + It.IsAny(), + It.IsAny(), + CancellationToken.None)).ReturnsAsync(m_ValidCreateIndexResponse); + + var fields = JsonConvert.SerializeObject(k_ValidIndexFields); + var visibility = CustomIndexVisibilityTypes.Default; + + var actual = await m_CloudSaveDataService!.CreateCustomIndexAsync( + TestValues.ValidProjectId, TestValues.ValidEnvironmentId, fields, visibility, null, CancellationToken.None); + + m_DataApiAsyncMock.VerifyAll(); + Assert.Multiple(() => + { + Assert.That(actual.Id, Is.EqualTo(m_ValidCreateIndexResponse.Id)); + Assert.That(actual.Status, Is.EqualTo(m_ValidCreateIndexResponse.Status)); + }); + } + + [Test] + public async Task CreateCustomIndex_FailsWithInvalidBody() + { + string mockErrorMsg; + m_ValidatorObject.Setup(v => v.IsConfigValid(It.IsAny(), It.IsAny(), out mockErrorMsg)) + .Returns(true); + + var body = "somebadjson"; + var visibility = CustomIndexVisibilityTypes.Default; + + try + { + var actual = await m_CloudSaveDataService!.CreateCustomIndexAsync( + TestValues.ValidProjectId, + TestValues.ValidEnvironmentId, + null, + visibility, + body, + CancellationToken.None); + Assert.Fail(); // Should not get this far + } + catch (CliException e) + { + Assert.That(e.Message, Does.Contain("Failed to deserialize object for Cloud Save request.")); + } + } + + [Test] + public async Task CreateCustomIndex_FailsWithBothBodyAndFieldsSpecified() + { + string mockErrorMsg; + m_ValidatorObject.Setup(v => v.IsConfigValid(It.IsAny(), It.IsAny(), out mockErrorMsg)) + .Returns(true); + + var body = JsonConvert.SerializeObject(m_ValidCreateIndexBody); + var fields = JsonConvert.SerializeObject(k_ValidIndexFields); + var visibility = CustomIndexVisibilityTypes.Default; + + try + { + var actual = await m_CloudSaveDataService!.CreateCustomIndexAsync( + TestValues.ValidProjectId, + TestValues.ValidEnvironmentId, + fields, + visibility, + body, + CancellationToken.None); + Assert.Fail(); // Should not get this far + } + catch (CliException e) + { + Assert.That(e.Message, Does.Contain("Index body and fields cannot both be specified.")); + } + } + + [Test] + public async Task CreateCustomIndex_Private_SerializedBody_Success() + { + string mockErrorMsg; + m_ValidatorObject.Setup(v => v.IsConfigValid(It.IsAny(), It.IsAny(), out mockErrorMsg)) + .Returns(true); + + m_DataApiAsyncMock.Setup( + t => t.CreatePrivateCustomIndexAsync( + It.Is(id => id.ToString() == TestValues.ValidProjectId), + It.Is(id => id.ToString() == TestValues.ValidEnvironmentId), + It.IsAny(), + It.IsAny(), + CancellationToken.None)).ReturnsAsync(m_ValidCreateIndexResponse); + + var body = JsonConvert.SerializeObject(m_ValidCreateIndexBody); + var visibility = CustomIndexVisibilityTypes.Private; + + var actual = await m_CloudSaveDataService!.CreateCustomIndexAsync( + TestValues.ValidProjectId, TestValues.ValidEnvironmentId, null, visibility, body, CancellationToken.None); + + m_DataApiAsyncMock.VerifyAll(); + Assert.Multiple(() => + { + Assert.That(actual.Id, Is.EqualTo(m_ValidCreateIndexResponse.Id)); + Assert.That(actual.Status, Is.EqualTo(m_ValidCreateIndexResponse.Status)); + }); + } + + [Test] + public async Task CreateCustomIndex_Private_SerializedFields_Success() + { + string mockErrorMsg; + m_ValidatorObject.Setup(v => v.IsConfigValid(It.IsAny(), It.IsAny(), out mockErrorMsg)) + .Returns(true); + + m_DataApiAsyncMock.Setup( + t => t.CreatePrivateCustomIndexAsync( + It.Is(id => id.ToString() == TestValues.ValidProjectId), + It.Is(id => id.ToString() == TestValues.ValidEnvironmentId), + It.IsAny(), + It.IsAny(), + CancellationToken.None)).ReturnsAsync(m_ValidCreateIndexResponse); + + var fields = JsonConvert.SerializeObject(k_ValidIndexFields); + var visibility = CustomIndexVisibilityTypes.Private; + + var actual = await m_CloudSaveDataService!.CreateCustomIndexAsync( + TestValues.ValidProjectId, TestValues.ValidEnvironmentId, fields, visibility, null, CancellationToken.None); + + m_DataApiAsyncMock.VerifyAll(); + Assert.Multiple(() => + { + Assert.That(actual.Id, Is.EqualTo(m_ValidCreateIndexResponse.Id)); + Assert.That(actual.Status, Is.EqualTo(m_ValidCreateIndexResponse.Status)); + }); + } + + [Test] + public async Task CreateCustomIndex_Private_FailsWithInvalidBody() + { + string mockErrorMsg; + m_ValidatorObject.Setup(v => v.IsConfigValid(It.IsAny(), It.IsAny(), out mockErrorMsg)) + .Returns(true); + + var body = "somebadjson"; + var visibility = CustomIndexVisibilityTypes.Default; + + try + { + var actual = await m_CloudSaveDataService!.CreateCustomIndexAsync( + TestValues.ValidProjectId, + TestValues.ValidEnvironmentId, + null, + visibility, + body, + CancellationToken.None); + Assert.Fail(); // Should not get this far + } + catch (CliException e) + { + Assert.That(e.Message, Does.Contain("Failed to deserialize object for Cloud Save request.")); + } + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudSave.UnitTest/Unity.Services.Cli.CloudSave.UnitTest.csproj b/Unity.Services.Cli/Unity.Services.Cli.CloudSave.UnitTest/Unity.Services.Cli.CloudSave.UnitTest.csproj new file mode 100644 index 0000000..2f63b94 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudSave.UnitTest/Unity.Services.Cli.CloudSave.UnitTest.csproj @@ -0,0 +1,28 @@ + + + net8.0 + enable + enable + false + + + true + + + true + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + \ No newline at end of file diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudSave.UnitTest/Utils/TestValues.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudSave.UnitTest/Utils/TestValues.cs new file mode 100644 index 0000000..881dc6e --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudSave.UnitTest/Utils/TestValues.cs @@ -0,0 +1,8 @@ +namespace Unity.Services.Cli.CloudSave.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.CloudSave/CloudSaveModule.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudSave/CloudSaveModule.cs new file mode 100644 index 0000000..1936894 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudSave/CloudSaveModule.cs @@ -0,0 +1,324 @@ +using System.CommandLine; +using System.Net; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Polly; +using RestSharp; +using Unity.Services.Cli.Common; +using Unity.Services.Cli.Common.Console; +using Unity.Services.Cli.Common.Input; +using Unity.Services.Cli.Common.Logging; +using Unity.Services.Cli.Common.Networking; +using Unity.Services.Cli.Common.Utils; +using Unity.Services.Cli.CloudSave.Handlers; +using Unity.Services.Cli.CloudSave.Input; +using Unity.Services.Cli.CloudSave.IO; +using Unity.Services.Cli.CloudSave.Service; +using Unity.Services.CloudSave.Authoring.Core.Fetch; +using Unity.Services.CloudSave.Authoring.Core.IO; +using Unity.Services.CloudSave.Authoring.Core.Service; +using Unity.Services.Gateway.CloudSaveApiV1.Generated.Api; +using FileSystem = Unity.Services.Cli.CloudSave.IO.FileSystem; +using IFileSystem = Unity.Services.CloudSave.Authoring.Core.IO.IFileSystem; + +namespace Unity.Services.Cli.CloudSave; + +/// +/// A Template module to achieve a get request command: ugs cloudsave get `address` -o `file` +/// +public class CloudSaveModule : ICommandModule +{ + class CloudSaveInput : CommonInput + { + public static readonly Argument AddressArgument = new( + "address", + "The address to send GET request"); + + [InputBinding(nameof(AddressArgument))] + public string? Address { get; set; } + + public static readonly Option OutputFileOption = new(new[] + { + "-o", + "--output" + }, "Write output to file instead of stdout"); + + [InputBinding(nameof(OutputFileOption))] + public string? OutputFile { get; set; } + } + + public Command ModuleRootCommand { get; } + public Command ListIndexesCommand { get; } + public Command QueryPlayerDataCommand { get; } + public Command QueryCustomDataCommand { get; } + public Command CreatePlayerIndexCommand { get; } + public Command CreateCustomIndexCommand { get; } + public Command ListCustomDataIdsCommand { get; } + public Command ListPlayerDataIdsCommand { get; } + + public CloudSaveModule() + { + ListIndexesCommand = new Command("list", "List all indexes.") + { + CommonInput.CloudProjectIdOption, + CommonInput.EnvironmentNameOption, + }; + ListIndexesCommand + .SetHandler< + CommonInput, + IUnityEnvironment, + ICloudSaveDataService, + ILogger, + ILoadingIndicator, + CancellationToken>( + ListIndexesHandler.ListIndexesAsync); + + QueryPlayerDataCommand = new Command("query", "Query player data.") + { + CommonInput.CloudProjectIdOption, + CommonInput.EnvironmentNameOption, + QueryDataInput.JsonFileOrBodyOption, + QueryDataInput.VisibilityOption + }; + QueryPlayerDataCommand + .SetHandler< + QueryDataInput, + IUnityEnvironment, + ICloudSaveDataService, + ILogger, + ILoadingIndicator, + CancellationToken>( + QueryPlayerDataHandler.QueryPlayerDataAsync); + + QueryCustomDataCommand = new Command("query", "Query custom entity data.") + { + CommonInput.CloudProjectIdOption, + CommonInput.EnvironmentNameOption, + QueryDataInput.JsonFileOrBodyOption, + QueryDataInput.VisibilityOption + }; + QueryCustomDataCommand + .SetHandler< + QueryDataInput, + IUnityEnvironment, + ICloudSaveDataService, + ILogger, + ILoadingIndicator, + CancellationToken>( + QueryCustomDataHandler.QueryCustomDataAsync); + + CreateCustomIndexCommand = new Command("create", "Create a custom index.") + { + CommonInput.CloudProjectIdOption, + CommonInput.EnvironmentNameOption, + CreateIndexInput.JsonFileOrBodyOption, + CreateIndexInput.FieldsOption, + CreateIndexInput.VisibilityOption + }; + CreateCustomIndexCommand + .SetHandler< + CreateIndexInput, + IUnityEnvironment, + ICloudSaveDataService, + ILogger, + ILoadingIndicator, + CancellationToken>( + CreateCustomIndexHandler.CreateCustomIndexAsync); + + CreatePlayerIndexCommand = new Command("create", "Create a new player data index.") + { + CommonInput.CloudProjectIdOption, + CommonInput.EnvironmentNameOption, + CreateIndexInput.JsonFileOrBodyOption, + CreateIndexInput.FieldsOption, + CreateIndexInput.VisibilityOption + }; + CreatePlayerIndexCommand + .SetHandler< + CreateIndexInput, + IUnityEnvironment, + ICloudSaveDataService, + ILogger, + ILoadingIndicator, + CancellationToken>( + CreatePlayerIndexHandler.CreatePlayerIndexAsync); + + ListCustomDataIdsCommand = new Command("list", "Get a paginated list of all Game State custom data IDs for a given project and environment.") + { + CommonInput.CloudProjectIdOption, + CommonInput.EnvironmentNameOption, + ListDataIdsInput.StartOption, + ListDataIdsInput.LimitOption + }; + ListCustomDataIdsCommand + .SetHandler< + ListDataIdsInput, + IUnityEnvironment, + ICloudSaveDataService, + ILogger, + ILoadingIndicator, + CancellationToken>( + ListCustomDataIdsHandler.ListCustomDataIdsAsync); + + ListPlayerDataIdsCommand = new Command("list", "Get a paginated list of all player data IDs for a given project and environment.") + { + CommonInput.CloudProjectIdOption, + CommonInput.EnvironmentNameOption, + ListDataIdsInput.StartOption, + ListDataIdsInput.LimitOption + }; + ListPlayerDataIdsCommand + .SetHandler< + ListDataIdsInput, + IUnityEnvironment, + ICloudSaveDataService, + ILogger, + ILoadingIndicator, + CancellationToken>( + ListPlayerDataIdsHandler.ListPlayerDataIdsAsync); + + var indexPlayerCommand = new Command("player", "Create player indexes.") + { + CreatePlayerIndexCommand, + }; + + var indexCustomCommand = new Command("custom", "Create custom indexes.") + { + CreateCustomIndexCommand, + }; + + var indexCommand = new Command("index", "Create, list, or edit indexes.") + { + ListIndexesCommand, + indexPlayerCommand, + indexCustomCommand, + }; + + var playerCommand = new Command("player", "Query player data.") + { + QueryPlayerDataCommand, + ListPlayerDataIdsCommand + }; + + var customCommand = new Command("custom", "Query custom entity data.") + { + QueryCustomDataCommand, + ListCustomDataIdsCommand + }; + + // APIs + var dataCommand = new Command("data", "Data API for Cloud Save.") + { + indexCommand, + playerCommand, + customCommand, + }; + + // Root + ModuleRootCommand = new("cloud-save", "Manage Cloud Save indexes.") + { + dataCommand + }; + ModuleRootCommand.AddAlias("cs"); + } + + /// + /// Handler for get request command to handle operations + /// + /// + /// CloudSave input automatically parsed. So developer does not need to retrieve from ParseResult. + /// + /// + /// The operation service for you command. + /// + /// + /// A singleton logger to log output for commands. + /// + /// A loading indicator to give user better feedback when some operation is taking time + /// File system abstraction + /// + /// A cancellation token that should be propagated as much as possible to allow the command operations to be cancelled at any time. + /// + static async Task GetAsync( + CloudSaveInput input, + ICloudSaveClient client, + ILogger logger, + ILoadingIndicator loadingIndicator, + IFileSystem fs, + CancellationToken cancellationToken) + { + await loadingIndicator.StartLoadingAsync("Sending Get Request...", + context => GetAsync(input, client, logger, fs, cancellationToken)); + } + + static async Task GetAsync( + CloudSaveInput input, + ICloudSaveClient client, + ILogger logger, + IFileSystem fs, + CancellationToken cancellationToken) + { + //TODO: This is a sample request for bootstrapping purposes + var result = await client.RawGetRequest(input.Address, cancellationToken); + + //Information log will be hidden with `--quiet` option. + logger.LogInformation("GET request succeed"); + + if (string.IsNullOrEmpty(input.OutputFile)) + { + // LogResultValue is to log a single result from service operation. + // It will be parsed to json format with `--json` option + logger.LogResultValue(result); + } + else + { + await fs.WriteAllText(input.OutputFile, result, cancellationToken); + } + } + + /// + /// Register service to UGS CLI host builder + /// + public static void RegisterServices(IServiceCollection serviceCollection) + { + var config = new Gateway.CloudSaveApiV1.Generated.Client.Configuration + { + BasePath = EndpointHelper.GetCurrentEndpointFor() + }; + config.DefaultHeaders.SetXClientIdHeader(); + AsyncPolicy retryAfterPolicy = Policy + .HandleResult(r => r.StatusCode == HttpStatusCode.TooManyRequests && r.Headers != null) + .WaitAndRetryAsync( + retryCount: 3, + sleepDurationProvider: RetryAfterSleepDuration, + onRetryAsync: (_, _, _, _) => Task.CompletedTask); + Gateway.LeaderboardApiV1.Generated.Client.RetryConfiguration.AsyncRetryPolicy = retryAfterPolicy; + serviceCollection.AddSingleton(new DataApi(config)); + serviceCollection.AddSingleton(); + + // Registers services required for Deployment/Fetch + // Register the command handler + //serviceCollection.AddTransient(); + //serviceCollection.AddTransient(); + serviceCollection.AddTransient(); + //serviceCollection.AddTransient(); + serviceCollection.AddTransient(); + serviceCollection.AddTransient(); + serviceCollection.AddTransient(); + + //Gateway.CloudSaveApiV1.Generated.Client.RetryConfiguration.RetryPolicy = + // RetryPolicy.GetHttpRetryPolicy(); + //Gateway.CloudSaveApiV1.Generated.Client.RetryConfiguration.AsyncRetryPolicy = + // RetryPolicy.GetAsyncHttpRetryPolicy(); + } + + public static TimeSpan RetryAfterSleepDuration(int retryCount, DelegateResult result, Context ctx) + { + const string retryAfter = "Retry-After"; + var header = result.Result.Headers!.First(x => x.Name!.Equals(retryAfter)); + var retryValue = header.Value?.ToString(); + var retryValueInt = int.Parse(retryValue!); + var time = 2 * retryValueInt; + return TimeSpan.FromSeconds(time); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudSave/Deploy/CloudSaveClient.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudSave/Deploy/CloudSaveClient.cs new file mode 100644 index 0000000..ed5ed45 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudSave/Deploy/CloudSaveClient.cs @@ -0,0 +1,171 @@ +using Microsoft.Extensions.Logging; +using Polly; +using Unity.Services.Cli.Common.Policies; +using Unity.Services.Cli.CloudSave.Exceptions; +using Unity.Services.Cli.CloudSave.IO; +using Unity.Services.Gateway.IdentityApiV1.Generated.Client; +using Unity.Services.CloudSave.Authoring.Core.IO; +using Unity.Services.CloudSave.Authoring.Core.Model; +using Unity.Services.CloudSave.Authoring.Core.Service; + +namespace Unity.Services.Cli.CloudSave.Deploy; + +class CloudSaveClient : ICloudSaveClient +{ + readonly ILogger m_Logger; + readonly string m_TempPath; + ICloudSaveSimpleResourceLoader m_Loader; + + public CloudSaveClient(ILogger logger) + { + m_Logger = logger; + //for demo purposes, mock remote storage in a temp dir + m_TempPath = Path.Join(Path.GetTempPath(), nameof(CloudSaveClient)); + m_Loader = new CloudSaveSimpleResourceLoader(new FileSystem()); + } + + public async Task Initialize( + string environmentId, + string projectId, + CancellationToken cancellationToken) + { + // This is sample code, it should be replaced + + //TODO: implement this method + //For demonstration purposes + if (!Directory.Exists(m_TempPath)) + { + var loader = new CloudSaveSimpleResourceLoader(new FileSystem()); + Directory.CreateDirectory(m_TempPath); + var mockSetup = Enumerable + .Range(0, 3) + .Select(CreateResource); + foreach (var res in mockSetup) + { + var item = new SimpleResourceDeploymentItem(res.Name, Path.Combine(m_TempPath, res.Id)) + { + Resource = res + }; + await loader.CreateOrUpdateResource(item, cancellationToken); + } + + SimpleResource CreateResource(int i) + { + return new SimpleResource() + { + Id = $"ID{i}", + Name = $"Name {i}", + AStrValue = "My Str {i}", + NestedObj = new NestedObject + { + NestedObjectString = i.ToString() + } + }; + } + } + } + + public async Task Get(string id, CancellationToken cancellationToken) + { + //TODO: implement this method + m_Logger.LogWarning("This is a sample client, it should not be shipped"); + var res = (SimpleResourceDeploymentItem)await m_Loader.ReadResource(Path.Combine(m_TempPath, id), CancellationToken.None); + return res.Resource; + } + + public Task Update(IResource resource, CancellationToken cancellationToken) + { + //TODO: implement this method + m_Logger.LogWarning("This is a sample client, it should not be shipped"); + + var item = new SimpleResourceDeploymentItem(resource.Id, Path.Combine(m_TempPath, resource.Id)) + { + Resource = resource + }; + return m_Loader.CreateOrUpdateResource(item, CancellationToken.None); + } + + public Task Create(IResource resource, CancellationToken cancellationToken) + { + //TODO: implement this method + m_Logger.LogWarning("This is a sample client, it should not be shipped"); + var item = new SimpleResourceDeploymentItem(resource.Id, Path.Combine(m_TempPath, resource.Id)) + { + Resource = resource + }; + return m_Loader.CreateOrUpdateResource(item, CancellationToken.None); + } + + public Task Delete(IResource resource, CancellationToken cancellationToken) + { + //TODO: implement this method + m_Logger.LogWarning("This is a sample client, it should not be shipped"); + var item = new SimpleResourceDeploymentItem(resource.Id, Path.Combine(m_TempPath, resource.Id)) + { + Resource = resource + }; + return m_Loader.DeleteResource(item, CancellationToken.None); + } + + public async Task> List(CancellationToken cancellationToken) + { + //TODO: implement this method + + // This is sample code, it should be replaced + var res = new List(); + foreach (var mockFilePath in Directory.EnumerateFiles(m_TempPath)) + { + var item = (SimpleResourceDeploymentItem)await m_Loader.ReadResource(mockFilePath, CancellationToken.None); + res.Add(item.Resource); + } + return res; + } + + static readonly HttpClient k_HttpClient = new(); + + public async Task RawGetRequest(string? address, CancellationToken cancellationToken = default) + { + try + { + if (string.IsNullOrEmpty(address)) + { + throw new CloudSaveException($"Invalid Address: {address}"); + } + + // In the case of raw http requests, you can use one of the CLI's retry policies to give the request + // another chance. + // + // Important: For service requests that are managed by the service's Generated Client, + // do not try to wrap your call in a retry block. You should instead follow the instructions in + // CloudSaveModule.cs RegisterServices() to set up automatic retries for your service calls. + var response = await Policy + .Handle() + .WaitAndRetryAsync( + 3, + _ => RetryPolicy.GetExponentialBackoffTimeSpan(), + (exception, span) => Task.CompletedTask) + .ExecuteAsync(async () => await k_HttpClient.GetAsync(address, cancellationToken)); + + response.EnsureSuccessStatusCode(); + var result = await response.Content.ReadAsStringAsync(cancellationToken); + return result; + } + catch (HttpRequestException exception) + { + //TODO: define you own service API exception. Here we use `HttpRequestException` as an example to simulate ApiException + throw new ApiException((int)exception.StatusCode!, exception.Message); + } + } + + static T ToPayload(IResource resource) where T : new() + { + //TODO: Implement + return new T(); + } + + static IResource FromPayload(T payload) where T : new() + { + //TODO: Implement + return new SimpleResource(); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudSave/Deploy/CloudSaveDeploymentService.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudSave/Deploy/CloudSaveDeploymentService.cs new file mode 100644 index 0000000..d7378de --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudSave/Deploy/CloudSaveDeploymentService.cs @@ -0,0 +1,91 @@ +using Spectre.Console; +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.CloudSave.Authoring.Core.Deploy; +using Unity.Services.CloudSave.Authoring.Core.IO; +using Unity.Services.CloudSave.Authoring.Core.Model; +using Unity.Services.CloudSave.Authoring.Core.Service; + +namespace Unity.Services.Cli.CloudSave.Deploy; + +class CloudSaveDeploymentService : IDeploymentService +{ + readonly ICloudSaveDeploymentHandler m_DeploymentHandler; + readonly ICloudSaveClient m_Client; + readonly ICloudSaveSimpleResourceLoader m_ResourceLoader; + readonly string m_ServiceType = "CloudSave"; + readonly string m_ServiceName = "module-template"; + + public CloudSaveDeploymentService( + ICloudSaveDeploymentHandler deploymentHandler, + ICloudSaveClient client, + ICloudSaveSimpleResourceLoader resourceLoader) + { + m_DeploymentHandler = deploymentHandler; + m_Client = client; + m_ResourceLoader = resourceLoader; + } + + public string ServiceType => m_ServiceType; + public string ServiceName => m_ServiceName; + public IReadOnlyList FileExtensions => new[] + { + Constants.SimpleFileExtension + }; + + public async Task Deploy( + DeployInput deployInput, + IReadOnlyList filePaths, + string projectId, + string environmentId, + StatusContext? loadingContext, + CancellationToken cancellationToken) + { + await m_Client.Initialize(environmentId, projectId, cancellationToken); + loadingContext?.Status($"Reading {m_ServiceType} files..."); + var(loaded, failedToLoad) = await GetResourcesFromFiles(filePaths); + + loadingContext?.Status($"Deploying {m_ServiceType} files..."); + var res = await m_DeploymentHandler.DeployAsync( + loaded, + dryRun: deployInput.DryRun, + reconcile: deployInput.Reconcile, token: cancellationToken); + + return new SimpleDeploymentResult( + res.Deployed.Concat(failedToLoad).ToList(), + ServiceType, + deployInput.DryRun); + } + + async Task<(IReadOnlyList, IReadOnlyList)> GetResourcesFromFiles(IReadOnlyList filePaths) + { + var resources = await Task.WhenAll(filePaths.Select(f => m_ResourceLoader.ReadResource(f, CancellationToken.None))); + + return (resources.Where(r => r.Status.MessageSeverity != SeverityLevel.Error).ToList(), + resources.Where(r => r.Status.MessageSeverity == SeverityLevel.Error).ToList()); + } + + class SimpleDeploymentResult : DeploymentResult + { + public SimpleDeploymentResult(IReadOnlyList authored, string service, bool dryRun) + : base(GetItemsOfType(authored, Constants.Updated), + GetItemsOfType(authored, Constants.Deleted), + GetItemsOfType(authored, Constants.Created), + GetItemsOfType(authored, string.Empty), + authored.Where(f => f.Status.MessageSeverity == SeverityLevel.Error).ToList(), + dryRun) { } + + static IReadOnlyList GetItemsOfType(IReadOnlyList source, string action) + { + return source.Where(f => IsDeployedPredicate(f, action)).ToList(); + } + + static bool IsDeployedPredicate(IDeploymentItem item, string action) + { + return item.Status.MessageSeverity == SeverityLevel.Success + && (item.Status.MessageDetail?.StartsWith(action) ?? false); + } + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudSave/Deploy/SimpleResourceConfigFile.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudSave/Deploy/SimpleResourceConfigFile.cs new file mode 100644 index 0000000..8b1bb2a --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudSave/Deploy/SimpleResourceConfigFile.cs @@ -0,0 +1,49 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Unity.Services.Cli.Authoring.Templates; +using Unity.Services.CloudSave.Authoring.Core.Model; + +namespace Unity.Services.Cli.CloudSave.Deploy; + +[Serializable] +class SimpleResourceConfigFile : SimpleResource, IFileTemplate +{ + // TODO: replace your schema here + [JsonProperty("$schema")] + public string Schema => "https://ugs-config-schemas.unity3d.com/v1/my-service.schema.json"; + + [JsonIgnore] + public string Extension => Constants.SimpleFileExtension; + + [JsonIgnore] + public string FileBodyText + { + get + { + var goodDefault = new SimpleResourceConfigFile() + { + AStrValue = "A Good Default", + Name = "GoodDefaultName", + NestedObj = new NestedObject + { + NestedObjectBoolean = true, + NestedObjectString = "A Good Nested Default" + } + }; + return JsonConvert.SerializeObject( + goodDefault, + GetSerializationSettings()); + } + } + + public static JsonSerializerSettings GetSerializationSettings() + { + var settings = new JsonSerializerSettings() + { + Converters = { new StringEnumConverter() }, + Formatting = Formatting.Indented, + DefaultValueHandling = DefaultValueHandling.IgnoreAndPopulate + }; + return settings; + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudSave/Exceptions/CloudSaveException.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudSave/Exceptions/CloudSaveException.cs new file mode 100644 index 0000000..8241ee4 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudSave/Exceptions/CloudSaveException.cs @@ -0,0 +1,25 @@ +using System.Runtime.Serialization; +using Unity.Services.Cli.Common.Exceptions; + +namespace Unity.Services.Cli.CloudSave.Exceptions; + +/// +/// Example of custom exception for incorrect user operation. +/// +public class CloudSaveException : CliException +{ + public CloudSaveException(int exitCode = Common.Exceptions.ExitCode.HandledError) + : base(exitCode) { } + + /// + /// constructor. + /// + /// A message with instructions to guide user how to fix the operation. + /// Exit code when this exception triggered. Default value is HandledError. + public CloudSaveException(string message, int exitCode = Common.Exceptions.ExitCode.HandledError) + : base(message, exitCode) { } + + public CloudSaveException( + string message, Exception innerException, int exitCode = Common.Exceptions.ExitCode.HandledError) + : base(message, innerException, exitCode) { } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudSave/Fetch/CloudSaveFetchService.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudSave/Fetch/CloudSaveFetchService.cs new file mode 100644 index 0000000..7faba31 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudSave/Fetch/CloudSaveFetchService.cs @@ -0,0 +1,95 @@ +using Spectre.Console; +using Unity.Services.Cli.Authoring.Input; +using Unity.Services.Cli.Authoring.Service; +using Unity.Services.DeploymentApi.Editor; +using Unity.Services.CloudSave.Authoring.Core.Fetch; +using Unity.Services.CloudSave.Authoring.Core.IO; +using Unity.Services.CloudSave.Authoring.Core.Model; +using Unity.Services.CloudSave.Authoring.Core.Service; +using FetchResult = Unity.Services.Cli.Authoring.Model.FetchResult; + +namespace Unity.Services.Cli.CloudSave.Fetch; + +class CloudSaveFetchService : IFetchService +{ + readonly ICloudSaveFetchHandler m_FetchHandler; + readonly ICloudSaveClient m_Client; + readonly ICloudSaveSimpleResourceLoader m_ResourceLoader; + readonly string m_ServiceType = "CloudSave"; + readonly string m_ServiceName = "module-template"; + + public CloudSaveFetchService( + ICloudSaveFetchHandler fetchHandler, + ICloudSaveClient client, + ICloudSaveSimpleResourceLoader resourceLoader) + { + m_FetchHandler = fetchHandler; + m_Client = client; + m_ResourceLoader = resourceLoader; + } + + public string ServiceType => m_ServiceType; + public string ServiceName => m_ServiceName; + + public IReadOnlyList FileExtensions => new[] + { + Constants.SimpleFileExtension + }; + + public async Task FetchAsync( + FetchInput input, + IReadOnlyList filePaths, + string projectId, + string environmentId, + StatusContext? loadingContext, + CancellationToken cancellationToken) + { + await m_Client.Initialize(environmentId, projectId, cancellationToken); + loadingContext?.Status($"Reading {m_ServiceType} files..."); + var (loadedSuccessfully, failedToLoad) = await GetResourcesFromFiles(filePaths); + + loadingContext?.Status($"Fetching {m_ServiceType} files..."); + var res = await m_FetchHandler.FetchAsync( + input.Path, + loadedSuccessfully, + input.DryRun, + input.Reconcile, + cancellationToken); + + return new SimpleFetchResult( + res.Fetched.Concat(failedToLoad).ToList(), + ServiceType, + input.DryRun + ); + } + + async Task<(IReadOnlyList, IReadOnlyList)> GetResourcesFromFiles(IReadOnlyList filePaths) + { + var resources = await Task.WhenAll(filePaths.Select(f => m_ResourceLoader.ReadResource(f, CancellationToken.None))); + + return (resources.Where(r => r.Status.MessageSeverity != SeverityLevel.Error).ToList(), + resources.Where(r => r.Status.MessageSeverity == SeverityLevel.Error).ToList()); + } + + class SimpleFetchResult : FetchResult + { + public SimpleFetchResult(IReadOnlyList authored, string service, bool dryRun) + : base(GetItemsOfType(authored, Constants.Updated), + GetItemsOfType(authored, Constants.Deleted), + GetItemsOfType(authored, Constants.Created), + GetItemsOfType(authored, string.Empty), + authored.Where(f => f.Status.MessageSeverity == SeverityLevel.Error).ToList(), + dryRun) { } + + static IReadOnlyList GetItemsOfType(IReadOnlyList source, string action) + { + return source.Where(f => IsDeployedPredicate(f, action)).ToList(); + } + + static bool IsDeployedPredicate(IDeploymentItem item, string action) + { + return item.Status.MessageSeverity == SeverityLevel.Success + && (item.Status.MessageDetail?.StartsWith(action) ?? false); + } + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudSave/Handlers/CreateCustomIndexHandler.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudSave/Handlers/CreateCustomIndexHandler.cs new file mode 100644 index 0000000..5f4d2ac --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudSave/Handlers/CreateCustomIndexHandler.cs @@ -0,0 +1,48 @@ +using Microsoft.Extensions.Logging; +using Unity.Services.Cli.CloudSave.Input; +using Unity.Services.Cli.CloudSave.Models; +using Unity.Services.Cli.CloudSave.Service; +using Unity.Services.Cli.CloudSave.Utils; +using Unity.Services.Cli.Common.Console; +using Unity.Services.Cli.Common.Exceptions; +using Unity.Services.Cli.Common.Logging; +using Unity.Services.Cli.Common.Utils; + +namespace Unity.Services.Cli.CloudSave.Handlers; + +static class CreateCustomIndexHandler +{ + public static async Task CreateCustomIndexAsync(CreateIndexInput input, IUnityEnvironment unityEnvironment, ICloudSaveDataService cloudSaveDataService, ILogger logger, + ILoadingIndicator loadingIndicator, CancellationToken cancellationToken) + { + await loadingIndicator.StartLoadingAsync("Fetching resources...", _ => + CreateCustomIndexAsync(input, unityEnvironment, cloudSaveDataService, logger, cancellationToken)); + } + + internal static async Task CreateCustomIndexAsync(CreateIndexInput input, IUnityEnvironment unityEnvironment, ICloudSaveDataService cloudSaveDataService, + ILogger logger, CancellationToken cancellationToken) + { + var projectId = input.CloudProjectId!; + var environmentId = await unityEnvironment.FetchIdentifierAsync(cancellationToken); + + if (string.IsNullOrEmpty(input.Visibility)) + { + input.Visibility = CustomIndexVisibilityTypes.Default; + } + + if (!CustomIndexVisibilityTypes.IsValidType(input.Visibility)) + { + throw new CliException($"Invalid visibility option: {input.Visibility}. Valid options are: {string.Join(", ", CustomIndexVisibilityTypes.GetTypes())}", ExitCode.HandledError); + } + + var response = await cloudSaveDataService.CreateCustomIndexAsync( + projectId: projectId, + environmentId: environmentId, + input.Fields, + input.Visibility, + RequestBodyHandler.GetRequestBodyFromFileOrInput(input.JsonFileOrBody, isRequired: input.Fields == null), + cancellationToken: cancellationToken); + + logger.LogResultValue(new CreateIndexOutput(response)); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudSave/Handlers/CreatePlayerIndexHandler.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudSave/Handlers/CreatePlayerIndexHandler.cs new file mode 100644 index 0000000..cac32fd --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudSave/Handlers/CreatePlayerIndexHandler.cs @@ -0,0 +1,48 @@ +using Microsoft.Extensions.Logging; +using Unity.Services.Cli.CloudSave.Input; +using Unity.Services.Cli.CloudSave.Models; +using Unity.Services.Cli.CloudSave.Service; +using Unity.Services.Cli.CloudSave.Utils; +using Unity.Services.Cli.Common.Console; +using Unity.Services.Cli.Common.Exceptions; +using Unity.Services.Cli.Common.Logging; +using Unity.Services.Cli.Common.Utils; + +namespace Unity.Services.Cli.CloudSave.Handlers; + +static class CreatePlayerIndexHandler +{ + public static async Task CreatePlayerIndexAsync(CreateIndexInput input, IUnityEnvironment unityEnvironment, ICloudSaveDataService cloudSaveDataService, ILogger logger, + ILoadingIndicator loadingIndicator, CancellationToken cancellationToken) + { + await loadingIndicator.StartLoadingAsync("Fetching resources...", _ => + CreatePlayerIndexAsync(input, unityEnvironment, cloudSaveDataService, logger, cancellationToken)); + } + + internal static async Task CreatePlayerIndexAsync(CreateIndexInput input, IUnityEnvironment unityEnvironment, ICloudSaveDataService cloudSaveDataService, + ILogger logger, CancellationToken cancellationToken) + { + var projectId = input.CloudProjectId!; + var environmentId = await unityEnvironment.FetchIdentifierAsync(cancellationToken); + + if (string.IsNullOrEmpty(input.Visibility)) + { + input.Visibility = PlayerIndexVisibilityTypes.Default; + } + + if (!PlayerIndexVisibilityTypes.IsValidType(input.Visibility)) + { + throw new CliException($"Invalid visibility option: {input.Visibility}. Valid options are: {string.Join(", ", PlayerIndexVisibilityTypes.GetTypes())}", ExitCode.HandledError); + } + + var response = await cloudSaveDataService.CreatePlayerIndexAsync( + projectId: projectId, + environmentId: environmentId, + input.Fields, + input.Visibility, + RequestBodyHandler.GetRequestBodyFromFileOrInput(input.JsonFileOrBody, isRequired: input.Fields == null), + cancellationToken: cancellationToken); + + logger.LogResultValue(new CreateIndexOutput(response)); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudSave/Handlers/ListCustomDataIdsHandler.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudSave/Handlers/ListCustomDataIdsHandler.cs new file mode 100644 index 0000000..cc2bea5 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudSave/Handlers/ListCustomDataIdsHandler.cs @@ -0,0 +1,36 @@ +using Microsoft.Extensions.Logging; +using Unity.Services.Cli.CloudSave.Input; +using Unity.Services.Cli.CloudSave.Models; +using Unity.Services.Cli.CloudSave.Service; +using Unity.Services.Cli.Common.Console; +using Unity.Services.Cli.Common.Input; +using Unity.Services.Cli.Common.Logging; +using Unity.Services.Cli.Common.Utils; + +namespace Unity.Services.Cli.CloudSave.Handlers; + +static class ListCustomDataIdsHandler +{ + public static async Task ListCustomDataIdsAsync(ListDataIdsInput input, IUnityEnvironment unityEnvironment, ICloudSaveDataService cloudSaveDataService, ILogger logger, + ILoadingIndicator loadingIndicator, CancellationToken cancellationToken) + { + await loadingIndicator.StartLoadingAsync("Fetching resources...", _ => + ListCustomDataIdsAsync(input, unityEnvironment, cloudSaveDataService, logger, cancellationToken)); + } + + internal static async Task ListCustomDataIdsAsync(ListDataIdsInput input, IUnityEnvironment unityEnvironment, ICloudSaveDataService cloudSaveDataService, + ILogger logger, CancellationToken cancellationToken) + { + var projectId = input.CloudProjectId!; + var environmentId = await unityEnvironment.FetchIdentifierAsync(cancellationToken); + + var response = await cloudSaveDataService.ListCustomDataIdsAsync( + projectId: projectId, + environmentId: environmentId, + start: input.Start!, + limit: input.Limit!, + cancellationToken: cancellationToken); + + logger.LogResultValue(input.IsJson ? response : response.ToJson()); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudSave/Handlers/ListIndexesHandler.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudSave/Handlers/ListIndexesHandler.cs new file mode 100644 index 0000000..b76bab7 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudSave/Handlers/ListIndexesHandler.cs @@ -0,0 +1,34 @@ +using Microsoft.Extensions.Logging; +using Unity.Services.Cli.CloudSave.Models; +using Unity.Services.Cli.CloudSave.Service; +using Unity.Services.Cli.Common.Console; +using Unity.Services.Cli.Common.Input; +using Unity.Services.Cli.Common.Logging; +using Unity.Services.Cli.Common.Utils; + +namespace Unity.Services.Cli.CloudSave.Handlers; + +static class ListIndexesHandler +{ + public static async Task ListIndexesAsync(CommonInput input, IUnityEnvironment unityEnvironment, ICloudSaveDataService cloudSaveDataService, ILogger logger, + ILoadingIndicator loadingIndicator, CancellationToken cancellationToken) + { + await loadingIndicator.StartLoadingAsync("Fetching resources...", _ => + ListIndexesAsync(input, unityEnvironment, cloudSaveDataService, logger, cancellationToken)); + } + + internal static async Task ListIndexesAsync(CommonInput input, IUnityEnvironment unityEnvironment, ICloudSaveDataService cloudSaveDataService, + ILogger logger, CancellationToken cancellationToken) + { + var projectId = input.CloudProjectId!; + var environmentId = await unityEnvironment.FetchIdentifierAsync(cancellationToken); + + var response = await cloudSaveDataService.ListIndexesAsync( + projectId: projectId, + environmentId: environmentId, + cancellationToken: cancellationToken); + var result = new ListIndexesOutput(response); + + logger.LogResultValue(result); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudSave/Handlers/ListPlayerDataIdsHandler.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudSave/Handlers/ListPlayerDataIdsHandler.cs new file mode 100644 index 0000000..2eef0a8 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudSave/Handlers/ListPlayerDataIdsHandler.cs @@ -0,0 +1,38 @@ +using Microsoft.Extensions.Logging; +using Unity.Services.Cli.CloudSave.Input; +using Unity.Services.Cli.CloudSave.Service; +using Unity.Services.Cli.Common.Console; +using Unity.Services.Cli.Common.Logging; +using Unity.Services.Cli.Common.Utils; + +namespace Unity.Services.Cli.CloudSave.Handlers; + +static class ListPlayerDataIdsHandler +{ + public static async Task ListPlayerDataIdsAsync(ListDataIdsInput input, IUnityEnvironment unityEnvironment, ICloudSaveDataService cloudSaveDataService, ILogger logger, + ILoadingIndicator loadingIndicator, CancellationToken cancellationToken) + { + await loadingIndicator.StartLoadingAsync("Fetching resources...", _ => + ListPlayerDataIdsAsync(input, unityEnvironment, cloudSaveDataService, logger, cancellationToken)); + } + + internal static async Task ListPlayerDataIdsAsync( + ListDataIdsInput input, + IUnityEnvironment unityEnvironment, + ICloudSaveDataService cloudSaveDataService, + ILogger logger, + CancellationToken cancellationToken) + { + var projectId = input.CloudProjectId!; + var environmentId = await unityEnvironment.FetchIdentifierAsync(cancellationToken); + + var response = await cloudSaveDataService.ListPlayerDataIdsAsync( + projectId: projectId, + environmentId: environmentId, + start: input.Start!, + limit: input.Limit!, + cancellationToken: cancellationToken); + + logger.LogResultValue(input.IsJson ? response : response.ToJson()); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudSave/Handlers/QueryCustomDataHandler.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudSave/Handlers/QueryCustomDataHandler.cs new file mode 100644 index 0000000..0abd4bc --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudSave/Handlers/QueryCustomDataHandler.cs @@ -0,0 +1,46 @@ +using Microsoft.Extensions.Logging; +using Unity.Services.Cli.CloudSave.Input; +using Unity.Services.Cli.CloudSave.Service; +using Unity.Services.Cli.CloudSave.Utils; +using Unity.Services.Cli.Common.Console; +using Unity.Services.Cli.Common.Exceptions; +using Unity.Services.Cli.Common.Logging; +using Unity.Services.Cli.Common.Utils; + +namespace Unity.Services.Cli.CloudSave.Handlers; + +static class QueryCustomDataHandler +{ + public static async Task QueryCustomDataAsync(QueryDataInput input, IUnityEnvironment unityEnvironment, ICloudSaveDataService cloudSaveDataService, ILogger logger, + ILoadingIndicator loadingIndicator, CancellationToken cancellationToken) + { + await loadingIndicator.StartLoadingAsync("Fetching resources...", _ => + QueryCustomDataAsync(input, unityEnvironment, cloudSaveDataService, logger, cancellationToken)); + } + + internal static async Task QueryCustomDataAsync(QueryDataInput input, IUnityEnvironment unityEnvironment, ICloudSaveDataService cloudSaveDataService, + ILogger logger, CancellationToken cancellationToken) + { + var projectId = input.CloudProjectId!; + var environmentId = await unityEnvironment.FetchIdentifierAsync(cancellationToken); + + if (string.IsNullOrEmpty(input.Visibility)) + { + input.Visibility = CustomIndexVisibilityTypes.Default; + } + + if (!CustomIndexVisibilityTypes.IsValidType(input.Visibility)) + { + throw new CliException($"Invalid visibility option: {input.Visibility}. Valid options are: {string.Join(", ", CustomIndexVisibilityTypes.GetTypes())}", ExitCode.HandledError); + } + + var response = await cloudSaveDataService.QueryCustomDataAsync( + projectId: projectId, + environmentId: environmentId, + input.Visibility, + RequestBodyHandler.GetRequestBodyFromFileOrInput(input.JsonFileOrBody, isRequired: true), + cancellationToken: cancellationToken); + + logger.LogResultValue(input.IsJson ? response : response.ToJson()); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudSave/Handlers/QueryPlayerDataHandler.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudSave/Handlers/QueryPlayerDataHandler.cs new file mode 100644 index 0000000..f38e390 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudSave/Handlers/QueryPlayerDataHandler.cs @@ -0,0 +1,46 @@ +using Microsoft.Extensions.Logging; +using Unity.Services.Cli.CloudSave.Input; +using Unity.Services.Cli.CloudSave.Service; +using Unity.Services.Cli.CloudSave.Utils; +using Unity.Services.Cli.Common.Console; +using Unity.Services.Cli.Common.Exceptions; +using Unity.Services.Cli.Common.Logging; +using Unity.Services.Cli.Common.Utils; + +namespace Unity.Services.Cli.CloudSave.Handlers; + +static class QueryPlayerDataHandler +{ + public static async Task QueryPlayerDataAsync(QueryDataInput input, IUnityEnvironment unityEnvironment, ICloudSaveDataService cloudSaveDataService, ILogger logger, + ILoadingIndicator loadingIndicator, CancellationToken cancellationToken) + { + await loadingIndicator.StartLoadingAsync("Fetching resources...", _ => + QueryPlayerDataAsync(input, unityEnvironment, cloudSaveDataService, logger, cancellationToken)); + } + + internal static async Task QueryPlayerDataAsync(QueryDataInput input, IUnityEnvironment unityEnvironment, ICloudSaveDataService cloudSaveDataService, + ILogger logger, CancellationToken cancellationToken) + { + var projectId = input.CloudProjectId!; + var environmentId = await unityEnvironment.FetchIdentifierAsync(cancellationToken); + + if (string.IsNullOrEmpty(input.Visibility)) + { + input.Visibility = PlayerIndexVisibilityTypes.Default; + } + + if (!PlayerIndexVisibilityTypes.IsValidType(input.Visibility)) + { + throw new CliException($"Invalid visibility option: {input.Visibility}. Valid options are: {string.Join(", ", PlayerIndexVisibilityTypes.GetTypes())}", ExitCode.HandledError); + } + + var response = await cloudSaveDataService.QueryPlayerDataAsync( + projectId: projectId, + environmentId: environmentId, + input.Visibility, + RequestBodyHandler.GetRequestBodyFromFileOrInput(input.JsonFileOrBody, isRequired: true), + cancellationToken: cancellationToken); + + logger.LogResultValue(input.IsJson ? response : response.ToJson()); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudSave/Handlers/RequestBodyHandler.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudSave/Handlers/RequestBodyHandler.cs new file mode 100644 index 0000000..ae4c264 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudSave/Handlers/RequestBodyHandler.cs @@ -0,0 +1,25 @@ +using Unity.Services.Cli.Common.Exceptions; + +namespace Unity.Services.Cli.CloudSave.Handlers; + +static class RequestBodyHandler +{ + internal static string GetRequestBodyFromFileOrInput(string? input, bool isRequired = false) + { + // Read the content from the file if the user provided a file path. + if (File.Exists(input)) + { + using var sr = new StreamReader(input); + return sr.ReadToEnd().Trim('\r', '\n'); + } + else // Otherwise, just default to using the raw input string. + { + if (isRequired && string.IsNullOrEmpty(input)) + { + throw new CliException("Required request body cannot be empty.", ExitCode.HandledError); + } + + return input ?? string.Empty; + } + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudSave/IO/CloudSaveSimpleResourceLoader.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudSave/IO/CloudSaveSimpleResourceLoader.cs new file mode 100644 index 0000000..a26b06c --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudSave/IO/CloudSaveSimpleResourceLoader.cs @@ -0,0 +1,112 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Unity.Services.Cli.CloudSave.Deploy; +using Unity.Services.CloudSave.Authoring.Core.IO; +using Unity.Services.CloudSave.Authoring.Core.Model; + +namespace Unity.Services.Cli.CloudSave.IO; + +public class CloudSaveSimpleResourceLoader : ICloudSaveSimpleResourceLoader +{ + readonly IFileSystem m_FileSystem; + readonly JsonSerializerSettings m_SerializerSettings; + + public CloudSaveSimpleResourceLoader(IFileSystem fileSystem) + { + m_FileSystem = fileSystem; + + m_SerializerSettings = new JsonSerializerSettings() + { + Converters = { new StringEnumConverter() }, + Formatting = Formatting.Indented, + DefaultValueHandling = DefaultValueHandling.IgnoreAndPopulate + }; + } + + public async Task ReadResource(string path, CancellationToken token) + { + var fileName = Path.GetFileName(path); + var deploymentItem = new SimpleResourceDeploymentItem(fileName, path); + try + { + var text = await m_FileSystem.ReadAllText(path, token); + var model = FromFile(JsonConvert.DeserializeObject(text, m_SerializerSettings)!); + deploymentItem.Resource = model; + // By default, use filename as the Id, unless overriden. + // This reduces cognitive complexity in having multiple "ids" for the same file + // and matches the same behavior as other services (like CloudCode where the name _is_ the ID) + if (model.Id == null) + model.Id = Path.GetFileNameWithoutExtension(fileName); + } + catch (IOException e) + { + deploymentItem.Status = Statuses.GetFailedToLoad(e, deploymentItem.Path);; + } + catch (JsonException e) + { + deploymentItem.Status = Statuses.GetFailedToRead(e, deploymentItem.Path); + } + + return deploymentItem; + } + + public async Task CreateOrUpdateResource(IResourceDeploymentItem deployableItem, CancellationToken token) + { + var fileName = Path.GetFileNameWithoutExtension(deployableItem.Path); + var id = deployableItem.Resource.Id; + try + { + // By default, use filename as the Id, unless overriden. + // This reduces cognitive complexity in having multiple "ids" for the same file + var fileModel = ToFile(deployableItem.Resource); + if (fileModel.Id == fileName) + fileModel.Id = null; + var text = JsonConvert.SerializeObject(deployableItem.Resource, m_SerializerSettings); + await m_FileSystem.WriteAllText(deployableItem.Path, text, token); + } + catch (JsonException e) + { + deployableItem.Status = Statuses.GetFailedToSerialize(e, deployableItem.Path); + } + catch (Exception e) + { + deployableItem.Status = Statuses.GetFailedToWrite(e, deployableItem.Path); + } + } + + public async Task DeleteResource(IResourceDeploymentItem deploymentItem, CancellationToken token) + { + try + { + await m_FileSystem.Delete(deploymentItem.Path, token); + } + catch (IOException e) + { + deploymentItem.Status = Statuses.GetFailedToDelete(e, deploymentItem.Path); + } + } + + static SimpleResource FromFile(SimpleResourceConfigFile fileModel) + { + //TODO: If your file format does not match your resource model (very likely), you should map from file model to resource model here + return new SimpleResource() + { + Id = fileModel.Id, + Name = fileModel.Name, + NestedObj = fileModel.NestedObj, + AStrValue = fileModel.AStrValue + }; + } + + static SimpleResourceConfigFile ToFile(IResource fileModel) + { + //TODO: If your file format does not match your resource model (very likely), you should map from resource model to file format here + return new SimpleResourceConfigFile() + { + Id = fileModel.Id, + Name = fileModel.Name, + NestedObj = fileModel.NestedObj, + AStrValue = fileModel.AStrValue, + }; + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudSave/IO/FileSystem.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudSave/IO/FileSystem.cs new file mode 100644 index 0000000..ee79f59 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudSave/IO/FileSystem.cs @@ -0,0 +1,5 @@ +using Unity.Services.CloudSave.Authoring.Core.IO; + +namespace Unity.Services.Cli.CloudSave.IO; + +class FileSystem : Common.IO.FileSystem, IFileSystem { } diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudSave/Input/CreateIndexInput.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudSave/Input/CreateIndexInput.cs new file mode 100644 index 0000000..ef06604 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudSave/Input/CreateIndexInput.cs @@ -0,0 +1,28 @@ +using System.CommandLine; +using Unity.Services.Cli.Common.Input; + +namespace Unity.Services.Cli.CloudSave.Input; + +class CreateIndexInput : CommonInput +{ + /* Optional request body as file input or raw string. */ + protected const string JsonBodyDescription = "If this is a file path, the content of the file is used; otherwise, the raw string is used."; + const string k_DefaultJsonBody = ""; + + public static readonly Option JsonFileOrBodyOption = new( + aliases: new[] { "-b", "--body" }, + getDefaultValue: () => k_DefaultJsonBody, + description: $"The JSON body. {JsonBodyDescription}" + ); + + [InputBinding(nameof(JsonFileOrBodyOption))] + public virtual string? JsonFileOrBody { get; set; } + + public static readonly Option FieldsOption = new Option("--fields", "An json string representing the array of fields in an index. Each field must be unique within the array."); + [InputBinding(nameof(FieldsOption))] + public string? Fields { get; set; } + + public static readonly Option VisibilityOption = new Option("--visibility", "A string representing the visibility of the index to be created."); + [InputBinding(nameof(VisibilityOption))] + public string? Visibility { get; set; } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudSave/Input/ListDataIdsInput.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudSave/Input/ListDataIdsInput.cs new file mode 100644 index 0000000..bc44e6f --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudSave/Input/ListDataIdsInput.cs @@ -0,0 +1,15 @@ +using System.CommandLine; +using Unity.Services.Cli.Common.Input; + +namespace Unity.Services.Cli.CloudSave.Input; + +class ListDataIdsInput : CommonInput +{ + public static readonly Option StartOption = new Option("--start", "The custom data ID to start the page from. If not specified, the first page will be returned."); + [InputBinding(nameof(StartOption))] + public string? Start { get; set; } + + public static readonly Option LimitOption = new Option("--limit", "The maximum number of custom data IDs to return. If not specified, the default limit of 20 will be used."); + [InputBinding(nameof(LimitOption))] + public int? Limit { get; set; } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudSave/Input/QueryDataInput.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudSave/Input/QueryDataInput.cs new file mode 100644 index 0000000..bb458ec --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudSave/Input/QueryDataInput.cs @@ -0,0 +1,24 @@ +using System.CommandLine; +using Unity.Services.Cli.Common.Input; + +namespace Unity.Services.Cli.CloudSave.Input; + +class QueryDataInput : CommonInput +{ + /* Optional request body as file input or raw string. */ + protected const string JsonBodyDescription = "If this is a file path, the content of the file is used; otherwise, the raw string is used."; + const string k_DefaultJsonBody = ""; + + public static readonly Option JsonFileOrBodyOption = new( + aliases: new[] { "-b", "--body" }, + getDefaultValue: () => k_DefaultJsonBody, + description: $"The JSON body. {JsonBodyDescription}" + ); + + [InputBinding(nameof(JsonFileOrBodyOption))] + public virtual string? JsonFileOrBody { get; set; } + + public static readonly Option VisibilityOption = new Option("--visibility", "A string representing the visibility of the index to be queried."); + [InputBinding(nameof(VisibilityOption))] + public string? Visibility { get; set; } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudSave/Models/CreateIndexOutput.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudSave/Models/CreateIndexOutput.cs new file mode 100644 index 0000000..82157e1 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudSave/Models/CreateIndexOutput.cs @@ -0,0 +1,26 @@ +using Newtonsoft.Json; +using Unity.Services.Gateway.CloudSaveApiV1.Generated.Model; + +namespace Unity.Services.Cli.CloudSave.Models; + +public class CreateIndexOutput +{ + public IndexStatus Status { get; set; } + public string Id { get; set; } + + public CreateIndexOutput(CreateIndexResponse response) + { + Status = response.Status; + Id = response.Id; + } + + public override string ToString() + { + return $"Index with ID \"{Id}\" successfully created with status \"{Status}\"."; + } + + public string ToJson() + { + return JsonConvert.SerializeObject(this); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudSave/Models/ListIndexesOutput.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudSave/Models/ListIndexesOutput.cs new file mode 100644 index 0000000..61ca021 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudSave/Models/ListIndexesOutput.cs @@ -0,0 +1,59 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Unity.Services.Gateway.CloudSaveApiV1.Generated.Model; + +namespace Unity.Services.Cli.CloudSave.Models; + +public class ListIndexesOutput +{ + public ListIndexesOutput(GetIndexIdsResponse response) + { + IndexIds = response.Indexes == null + ? new List() + : response.Indexes.Select(i => new LiveIndexConfigOutput(i)).ToList(); + } + + public override string ToString() + { + var jsonString = JsonConvert.SerializeObject(this); + var formattedJson = JToken.Parse(jsonString).ToString(Formatting.Indented); + return formattedJson; + } + + public List? IndexIds { get; } +} + +public class LiveIndexConfigOutput +{ + public LiveIndexConfigOutput(LiveIndexConfigInner response) + { + Id = response.Id; + Status = response.Status; + EntityType = response.EntityType; + AccessClass = response.AccessClass; + Fields = response.Fields == null ? new List() : response.Fields.Select(f => new IndexFieldOutput(f)).ToList(); + } + + public string Id { get; } + + public IndexStatus? Status { get; } + + public LiveIndexConfigInner.EntityTypeEnum? EntityType { get; } + + public AccessClass? AccessClass { get; } + + public List? Fields { get; } +} + +public class IndexFieldOutput +{ + public IndexFieldOutput(IndexField response) + { + Key = response.Key; + Ascending = response.Asc; + } + + public string Key { get; } + + public bool Ascending { get; } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudSave/Service/CloudSaveDataService.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudSave/Service/CloudSaveDataService.cs new file mode 100644 index 0000000..d761334 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudSave/Service/CloudSaveDataService.cs @@ -0,0 +1,212 @@ +using Newtonsoft.Json; +using Unity.Services.Cli.CloudSave.Utils; +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.CloudSaveApiV1.Generated.Api; +using Unity.Services.Gateway.CloudSaveApiV1.Generated.Model; + +namespace Unity.Services.Cli.CloudSave.Service; + +class CloudSaveDataService : ICloudSaveDataService +{ + readonly IServiceAccountAuthenticationService m_AuthenticationService; + readonly IDataApiAsync m_DataApiAsync; + readonly IConfigurationValidator m_ConfigValidator; + + public CloudSaveDataService(IDataApiAsync dataApiAsync, IConfigurationValidator validator, + IServiceAccountAuthenticationService authenticationService) + { + m_DataApiAsync = dataApiAsync; + m_ConfigValidator = validator; + m_AuthenticationService = authenticationService; + } + + public async Task ListIndexesAsync(string projectId, string environmentId, CancellationToken cancellationToken = default) + { + await AuthorizeServiceAsync(cancellationToken); + ValidateProjectIdAndEnvironmentId(projectId, environmentId); + + var response = await m_DataApiAsync.ListIndexesAsync( + projectId: Guid.Parse(projectId), + environmentId: Guid.Parse(environmentId), + cancellationToken: cancellationToken); + + return response; + } + + public async Task CreateCustomIndexAsync(string projectId, string environmentId, string? fields, string? visibility, string? body, CancellationToken cancellationToken = default) + { + await AuthorizeServiceAsync(cancellationToken); + ValidateProjectIdAndEnvironmentId(projectId, environmentId); + var createIndexBody = GetCreateIndexBody(fields, body); + + return visibility switch + { + CustomIndexVisibilityTypes.Private => await m_DataApiAsync.CreatePrivateCustomIndexAsync( + projectId: Guid.Parse(projectId), + environmentId: Guid.Parse(environmentId), + createIndexBody: createIndexBody, + cancellationToken: cancellationToken), + _ => await m_DataApiAsync.CreateDefaultCustomIndexAsync( + projectId: Guid.Parse(projectId), + environmentId: Guid.Parse(environmentId), + createIndexBody: createIndexBody, + cancellationToken: cancellationToken) + }; + } + + public async Task QueryPlayerDataAsync(string projectId, string environmentId, string? visibility, string? body, CancellationToken cancellationToken = default) + { + await AuthorizeServiceAsync(cancellationToken); + ValidateProjectIdAndEnvironmentId(projectId, environmentId); + var queryIndexBody = !string.IsNullOrEmpty(body) ? DeserializeOrThrow(body) : new QueryIndexBody(); + + return visibility switch + { + PlayerIndexVisibilityTypes.Public => await m_DataApiAsync.QueryPublicPlayerDataAsync( + projectId: Guid.Parse(projectId), + environmentId: Guid.Parse(environmentId), + queryIndexBody: queryIndexBody, + cancellationToken: cancellationToken), + PlayerIndexVisibilityTypes.Protected => await m_DataApiAsync.QueryProtectedPlayerDataAsync( + projectId: Guid.Parse(projectId), + environmentId: Guid.Parse(environmentId), + queryIndexBody: queryIndexBody, + cancellationToken: cancellationToken), + _ => await m_DataApiAsync.QueryDefaultPlayerDataAsync( + projectId: Guid.Parse(projectId), + environmentId: Guid.Parse(environmentId), + queryIndexBody: queryIndexBody, + cancellationToken: cancellationToken) + }; + } + + public async Task QueryCustomDataAsync(string projectId, string environmentId, string? visibility, string? body, CancellationToken cancellationToken = default) + { + await AuthorizeServiceAsync(cancellationToken); + ValidateProjectIdAndEnvironmentId(projectId, environmentId); + var queryIndexBody = !string.IsNullOrEmpty(body) ? DeserializeOrThrow(body) : new QueryIndexBody(); + + return visibility switch + { + CustomIndexVisibilityTypes.Private => await m_DataApiAsync.QueryPrivateCustomDataAsync( + projectId: Guid.Parse(projectId), + environmentId: Guid.Parse(environmentId), + queryIndexBody: queryIndexBody, + cancellationToken: cancellationToken), + _ => await m_DataApiAsync.QueryDefaultCustomDataAsync( + projectId: Guid.Parse(projectId), + environmentId: Guid.Parse(environmentId), + queryIndexBody: queryIndexBody, + cancellationToken: cancellationToken) + }; + } + + public async Task CreatePlayerIndexAsync(string projectId, string environmentId, string? fields, string? visibility, string? body, CancellationToken cancellationToken = default) + { + await AuthorizeServiceAsync(cancellationToken); + ValidateProjectIdAndEnvironmentId(projectId, environmentId); + var createIndexBody = GetCreateIndexBody(fields, body); + + return visibility switch + { + PlayerIndexVisibilityTypes.Public => await m_DataApiAsync.CreatePublicPlayerIndexAsync( + projectId: Guid.Parse(projectId), + environmentId: Guid.Parse(environmentId), + createIndexBody: createIndexBody, + cancellationToken: cancellationToken), + PlayerIndexVisibilityTypes.Protected => await m_DataApiAsync.CreateProtectedPlayerIndexAsync( + projectId: Guid.Parse(projectId), + environmentId: Guid.Parse(environmentId), + createIndexBody: createIndexBody, + cancellationToken: cancellationToken), + _ => await m_DataApiAsync.CreateDefaultPlayerIndexAsync( + projectId: Guid.Parse(projectId), + environmentId: Guid.Parse(environmentId), + createIndexBody: createIndexBody, + cancellationToken: cancellationToken) + }; + } + + public async Task ListCustomDataIdsAsync(string projectId, string environmentId, string? start, int? limit, CancellationToken cancellationToken = default) + { + await AuthorizeServiceAsync(cancellationToken); + ValidateProjectIdAndEnvironmentId(projectId, environmentId); + + var response = await m_DataApiAsync.ListCustomDataIDsAsync( + projectId: Guid.Parse(projectId), + environmentId: Guid.Parse(environmentId), + start: start, + limit: limit, + cancellationToken: cancellationToken); + + return response; + } + + public async Task ListPlayerDataIdsAsync(string projectId, string environmentId, string? start, int? limit, CancellationToken cancellationToken = default) + { + await AuthorizeServiceAsync(cancellationToken); + ValidateProjectIdAndEnvironmentId(projectId, environmentId); + + var response = await m_DataApiAsync.GetPlayersWithItemsAsync( + projectId: Guid.Parse(projectId), + environmentId: Guid.Parse(environmentId), + start: start, + limit: limit, + cancellationToken: cancellationToken); + + return response; + } + + static CreateIndexBody GetCreateIndexBody(string? fields, string? body) + { + if (!string.IsNullOrEmpty(fields) && !string.IsNullOrEmpty(body)) + { + throw new CliException($"Index body and fields cannot both be specified.", ExitCode.HandledError); + } + + if (!string.IsNullOrEmpty(fields)) + { + var fieldsObject = DeserializeOrThrow>(fields); + return new CreateIndexBody(new CreateIndexBodyIndexConfig(fieldsObject)); + } + + if (!string.IsNullOrEmpty(body)) + { + var bodyObject = DeserializeOrThrow(body); + return bodyObject ?? new CreateIndexBody(); + } + + throw new CliException($"Index body or fields is required.", ExitCode.HandledError); + } + + internal async Task AuthorizeServiceAsync(CancellationToken cancellationToken = default) + { + var token = await m_AuthenticationService.GetAccessTokenAsync(cancellationToken); + m_DataApiAsync.Configuration.DefaultHeaders.SetAccessTokenHeader(token); + } + + internal void ValidateProjectIdAndEnvironmentId(string projectId, string environmentId) + { + m_ConfigValidator.ThrowExceptionIfConfigInvalid(Keys.ConfigKeys.ProjectId, projectId); + m_ConfigValidator.ThrowExceptionIfConfigInvalid(Keys.ConfigKeys.EnvironmentId, environmentId); + } + + /// + /// Helper function to wrap JSON deserialization in order to throw a for errors. + /// + static T? DeserializeOrThrow(string value) + { + try + { + return JsonConvert.DeserializeObject(value); + } + catch (Exception ex) + { + throw new CliException($"Failed to deserialize object for Cloud Save request. ({ex.Message})", ex, ExitCode.HandledError); + } + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudSave/Service/ICloudSaveDataService.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudSave/Service/ICloudSaveDataService.cs new file mode 100644 index 0000000..9b048cd --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudSave/Service/ICloudSaveDataService.cs @@ -0,0 +1,14 @@ +using Unity.Services.Gateway.CloudSaveApiV1.Generated.Model; + +namespace Unity.Services.Cli.CloudSave.Service; + +interface ICloudSaveDataService +{ + public Task ListIndexesAsync(string projectId, string environmentId, CancellationToken cancellationToken = default); + public Task CreateCustomIndexAsync(string projectId, string environmentId, string? fields, string? visibility, string? body, CancellationToken cancellationToken = default); + public Task QueryPlayerDataAsync(string projectId, string environmentId, string? visibility, string body, CancellationToken cancellationToken = default); + public Task QueryCustomDataAsync(string projectId, string environmentId, string? visibility, string body, CancellationToken cancellationToken = default); + public Task CreatePlayerIndexAsync(string projectId, string environmentId, string? fields, string? visibility, string? body, CancellationToken cancellationToken = default); + public Task ListCustomDataIdsAsync(string projectId, string environmentId, string? start, int? limit, CancellationToken cancellationToken = default); + public Task ListPlayerDataIdsAsync(string projectId, string environmentId, string? start, int? limit, CancellationToken cancellationToken = default); +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudSave/Unity.Services.Cli.CloudSave.csproj b/Unity.Services.Cli/Unity.Services.Cli.CloudSave/Unity.Services.Cli.CloudSave.csproj new file mode 100644 index 0000000..2161ffd --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudSave/Unity.Services.Cli.CloudSave.csproj @@ -0,0 +1,39 @@ + + + net8.0 + 10 + enable + enable + true + + + TRACE;FEATURE_CLOUDSAVE_DEPLOY;FEATURE_CLOUDSAVE_IMPORT_EXPORT;FEATURE_CLOUDSAVE_CREATE_UPDATE + + + TRACE;FEATURE_CLOUDSAVE_DEPLOY;FEATURE_CLOUDSAVE_IMPORT_EXPORT + + + + <_Parameter1>$(AssemblyName).UnitTest + + + <_Parameter1>Unity.Services.Cli.IntegrationTest + + + <_Parameter1>DynamicProxyGenAssembly2 + + + + + + + + + + + + + + $(DefineConstants);$(ExtraDefineConstants) + + \ No newline at end of file diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudSave/Utils/CustomIndexVisibilityTypes.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudSave/Utils/CustomIndexVisibilityTypes.cs new file mode 100644 index 0000000..c54f346 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudSave/Utils/CustomIndexVisibilityTypes.cs @@ -0,0 +1,17 @@ +namespace Unity.Services.Cli.CloudSave.Utils; + +static class CustomIndexVisibilityTypes +{ + public const string Default = "default"; + public const string Private = "private"; + + public static bool IsValidType(string? type) + { + return type is Default or Private; + } + + public static IEnumerable GetTypes() + { + return new List { Default, Private }; + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudSave/Utils/PlayerIndexVisibilityTypes.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudSave/Utils/PlayerIndexVisibilityTypes.cs new file mode 100644 index 0000000..b7472a1 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudSave/Utils/PlayerIndexVisibilityTypes.cs @@ -0,0 +1,18 @@ +namespace Unity.Services.Cli.CloudSave.Utils; + +static class PlayerIndexVisibilityTypes +{ + public const string Default = "default"; + public const string Public = "public"; + public const string Protected = "protected"; + + public static bool IsValidType(string? type) + { + return type is Default or Public or Protected; + } + + public static IEnumerable GetTypes() + { + return new List { Default, Public, Protected }; + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Common/Input/ConfigurationInput.cs b/Unity.Services.Cli/Unity.Services.Cli.Common/Input/ConfigurationInput.cs index f865f55..4f7c1cb 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.Common/Input/ConfigurationInput.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.Common/Input/ConfigurationInput.cs @@ -43,6 +43,7 @@ public class ConfigurationInput : CommonInput ) { Arity = ArgumentArity.OneOrMore, + AllowMultipleArgumentsPerToken = true }; static ConfigurationInput() 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 18730ec..0c9f269 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 @@ -14,7 +14,7 @@ - + diff --git a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Services/GameServerHostingConfigLoader.cs b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Services/GameServerHostingConfigLoader.cs index e81be57..a9f367b 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Services/GameServerHostingConfigLoader.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Services/GameServerHostingConfigLoader.cs @@ -8,7 +8,7 @@ namespace Unity.Services.Cli.GameServerHosting.Services; class GameServerHostingConfigLoader : IGameServerHostingConfigLoader { - const string k_Extension = ".gsh"; + internal const string k_Extension = ".gsh"; readonly IDeployFileService m_DeployFileService; readonly IMultiplayConfigValidator m_ConfigValidator; 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 c6a90f7..381364d 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 @@ -16,6 +16,12 @@ <_Parameter1>DynamicProxyGenAssembly2 + + <_Parameter1>Unity.Services.Cli.Matchmaker.UnitTest + + + <_Parameter1>Unity.Services.Cli.Matchmaker + 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 93d5add..5e3777d 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 @@ -14,7 +14,7 @@ public class AccessApiMock : IServiceApiMock readonly string m_ProjectId; readonly string m_EnvironmentId; readonly string m_PlayerId; - readonly string k_AccessModulePath = "/access/v1"; + const string k_AccessModulePath = "/access/resource-policy/v1"; readonly string m_ProjectPolicyUrl; readonly string m_PlayerPolicyUrl; @@ -35,7 +35,7 @@ public AccessApiMock() static Policy GetPolicy() { - var statement = new Statement( + var statement = new ProjectStatement( "statement-1", new List() { @@ -44,14 +44,14 @@ static Policy GetPolicy() "Deny", "Player", "urn:ugs:*"); - List statementLists = new List() { statement }; + List statementLists = new List() { statement }; var policy = new Policy(statementLists); return policy; } PlayerPolicy GetPlayerPolicy() { - List statementLists = new List(); + List statementLists = new List(); var policy = new PlayerPolicy(playerId: m_PlayerId, statementLists); return policy; diff --git a/Unity.Services.Cli/Unity.Services.Cli.Integration.MockServer/ServiceMocks/CloudCode/CloudCodeFetchMock.cs b/Unity.Services.Cli/Unity.Services.Cli.Integration.MockServer/ServiceMocks/CloudCode/CloudCodeFetchMock.cs index 0c074d8..6cac28f 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.Integration.MockServer/ServiceMocks/CloudCode/CloudCodeFetchMock.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.Integration.MockServer/ServiceMocks/CloudCode/CloudCodeFetchMock.cs @@ -22,22 +22,22 @@ public class CloudCodeFetchMock : IServiceApiMock { new( "remoteScript1", - ScriptType.API, - Language.JS, + "API", + "JS", true, DateTime.MinValue, 1), new( "remoteScript2", - ScriptType.API, - Language.JS, + "API", + "JS", true, DateTime.MinValue, 1), new( "remoteScript3", - ScriptType.API, - Language.JS, + "API", + "JS", true, DateTime.MinValue, 1) diff --git a/Unity.Services.Cli/Unity.Services.Cli.Integration.MockServer/ServiceMocks/CloudCode/CloudCodeV1Mock.cs b/Unity.Services.Cli/Unity.Services.Cli.Integration.MockServer/ServiceMocks/CloudCode/CloudCodeV1Mock.cs index 5bcbf1e..a9d1968 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.Integration.MockServer/ServiceMocks/CloudCode/CloudCodeV1Mock.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.Integration.MockServer/ServiceMocks/CloudCode/CloudCodeV1Mock.cs @@ -1,5 +1,4 @@ using System.Net; -using OpenTelemetry.Trace; using Unity.Services.Cli.MockServer.Common; using Unity.Services.Gateway.CloudCodeApiV1.Generated.Model; using WireMock.Admin.Mappings; @@ -39,14 +38,14 @@ static void MockListCSharpModule(WireMockServer mockServer) { new( "ExistingModule", - Language.CS, + "CS", null, k_SampleUrl ), new( "AnotherExistingModule", - Language.CS, + "CS", null, k_SampleUrl ) 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 ac3b26b..eecf0f7 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 @@ -12,7 +12,7 @@ - + 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 9c24ee8..f3eb957 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/AccessTests/AccessTests.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/AccessTests/AccessTests.cs @@ -109,7 +109,7 @@ public async Task AccessGetPlayerPolicyReturnsZeroExitCode() var playerPolicy = new { playerId = AccessApiMock.PlayerId, - statements = new List() + statements = new List() }; await AssertSuccess($"access get-player-policy {AccessApiMock.PlayerId}", expectedStdOut: JsonConvert.SerializeObject(playerPolicy, Formatting.Indented)); @@ -125,7 +125,7 @@ public async Task AccessGetAllPlayerPoliciesReturnsZeroExitCode() var playerPolicy = new { playerId = AccessApiMock.PlayerId, - statements = new List() + statements = new List() }; List obj = new List(); 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 b6befc1..99a7454 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 @@ -99,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} -s cloud-code-scripts -s cloud-code-modules") + .Command($"deploy {k_TestDirectory} -s cloud-code-scripts cloud-code-modules") .AssertStandardOutputContains($"Successfully deployed the following files:{Environment.NewLine} {deployedConfigFileString}") .AssertNoErrors() .ExecuteAsync(); 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 20f438e..4493c86 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/CloudCodeTests/CloudCodeScriptTests.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/CloudCodeTests/CloudCodeScriptTests.cs @@ -320,38 +320,6 @@ public async Task CloudCodeCreateThrowsErrorWhenEnvironmentNotSet() .ExecuteAsync(); } - [Test] - public async Task CloudCodeCreateThrowsErrorWhenScriptTypeIsInvalid() - { - const string invalidScriptType = "invalidtype"; - var expectedMsg = $"'{invalidScriptType}' is not a valid {nameof(ScriptType)}." - + $" Valid {nameof(ScriptType)}: " + string.Join(",", Enum.GetNames()) + "."; - SetConfigValue("project-id", CommonKeys.ValidProjectId); - SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); - - await GetLoggedInCli() - .Command($"cloud-code scripts create scriptname invalidpath -t {invalidScriptType}") - .AssertExitCode(ExitCode.HandledError) - .AssertStandardErrorContains(expectedMsg) - .ExecuteAsync(); - } - - [Test] - public async Task CloudCodeCreateThrowsErrorWhenScriptLanguageIsInvalid() - { - const string invalidScriptLanguage = "invalidlanguage"; - var expectedMsg = $"'{invalidScriptLanguage}' is not a valid {nameof(Language)}." - + $" Valid {nameof(Language)}: " + string.Join(",", Enum.GetNames()) + "."; - SetConfigValue("project-id", CommonKeys.ValidProjectId); - SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); - - await GetLoggedInCli() - .Command($"cloud-code scripts create scriptname invalidpath -l {invalidScriptLanguage}") - .AssertExitCode(ExitCode.HandledError) - .AssertStandardErrorContains(expectedMsg) - .ExecuteAsync(); - } - [TestCase("\"\"")] public async Task CloudCodeCreateThrowsErrorWhenFilepathIsInvalid(string? filepath) { diff --git a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/CloudSaveTests/CloudSaveTests.cs b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/CloudSaveTests/CloudSaveTests.cs new file mode 100644 index 0000000..bf1b34c --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/CloudSaveTests/CloudSaveTests.cs @@ -0,0 +1,577 @@ +using System.Threading.Tasks; +using Newtonsoft.Json; +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.CloudSaveTests; + +public class CloudSaveTests : UgsCliFixture +{ + const string k_Default = "default"; + const string k_Private = "private"; + const string k_Public = "public"; + const string k_Protected = "protected"; + + const string k_NotLoggedInOutput = + " You are not logged into any service account. Please login using the 'ugs login' command."; + + const string k_CreateIndexBodyMissingOutput = + "Required request body cannot be empty."; + + const string k_CreatePlayerIndexInvalidVisibilityOutput = + "Valid options are: default, public, protected"; + + const string k_CreateCustomIndexInvalidVisibilityOutput = + "Valid options are: default, private"; + + const string k_MissingProjectIdOutput = "'project-id' is not set in project configuration"; + const string k_MissingEnvironmentNameOutput = "'environment-name' is not set in project configuration"; + + static readonly string k_ValidQueryIndexBody = + "{\"fields\":[{\"op\":\"EQ\",\"key\":\"fieldFilter_key\",\"value\":\"fieldFilter_value\",\"asc\":true}],\"returnKeys\":[\"returnKey1\",\"returnKey2\"],\"offset\":5,\"limit\":10}"; + + static readonly string k_ValidCreateIndexFields = + "[{\"key\":\"key1\",\"asc\":true},{\"key\":\"key2\",\"asc\":false}]"; + + static readonly string k_ValidCreateIndexBody = + "{\"indexConfig\": {\"fields\":" + k_ValidCreateIndexFields + "}}"; + + [SetUp] + public async Task SetUp() + { + DeleteLocalConfig(); + DeleteLocalCredentials(); + + await MockApi.MockServiceAsync(new CloudSaveApiMock()); + await MockApi.MockServiceAsync(new IdentityV1Mock()); + } + + [TearDown] + public void TearDown() + { + DeleteLocalConfig(); + DeleteLocalCredentials(); + MockApi.Server?.ResetMappings(); + } + + [Test] + public async Task CloudSave_ListIndexes_ThrowsWhenNotAuthenticated() + { + SetConfigValue("project-id", CommonKeys.ValidProjectId); + SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); + + await new UgsCliTestCase() + .Command($"cloud-save data index list") + .AssertExitCode(ExitCode.HandledError) + .AssertStandardErrorContains(k_NotLoggedInOutput) + .ExecuteAsync(); + } + + [Test] + public async Task CloudSave_ListIndexes_ThrowsWithProjectIdMissing() + { + SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); + + await new UgsCliTestCase() + .Command($"cloud-save data index list") + .AssertExitCode(ExitCode.HandledError) + .AssertStandardErrorContains(k_MissingProjectIdOutput) + .ExecuteAsync(); + } + + [Test] + public async Task CloudSave_ListIndexes_ThrowsWithProjectIdEmpty() + { + SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); + + await new UgsCliTestCase() + .Command($"cloud-save data index list --project-id \"\"") + .AssertExitCode(ExitCode.HandledError) + .AssertStandardErrorContains(k_MissingProjectIdOutput) + .ExecuteAsync(); + } + + [Test] + public async Task CloudSave_ListIndexes_ThrowsWithEnvironmentNameMissing() + { + SetConfigValue("project-id", CommonKeys.ValidProjectId); + + await new UgsCliTestCase() + .Command($"cloud-save data index list") + .AssertExitCode(ExitCode.HandledError) + .AssertStandardErrorContains(k_MissingEnvironmentNameOutput) + .ExecuteAsync(); + } + + [Test] + public async Task CloudSave_ListIndexes_SucceedsWithValidInput() + { + SetConfigValue("project-id", CommonKeys.ValidProjectId); + SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); + + await GetLoggedInCli() + .Command($"cloud-save data index list") + .AssertNoErrors() + .DebugCommand("CloudSave_ListIndexes_SucceedsWithValidInput") + .ExecuteAsync(); + } + + [Test] + public async Task CloudSave_ListCustomIds_SucceedsWithValidInput() + { + SetConfigValue("project-id", CommonKeys.ValidProjectId); + SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); + + await GetLoggedInCli() + .Command($"cloud-save data custom list --limit 2 --start \"someId\"") + .AssertNoErrors() + .DebugCommand("CloudSave_ListCustomIds_SucceedsWithValidInput") + .ExecuteAsync(); + } + + [Test] + public async Task CloudSave_ListCustomIds_SucceedsWithNoInput() + { + SetConfigValue("project-id", CommonKeys.ValidProjectId); + SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); + + await GetLoggedInCli() + .Command($"cloud-save data custom list") + .AssertNoErrors() + .DebugCommand("CloudSave_ListCustomIds_SucceedsWithNoInput") + .ExecuteAsync(); + } + + [Test] + public async Task CloudSave_ListPlayerIds_SucceedsWithValidInput() + { + SetConfigValue("project-id", CommonKeys.ValidProjectId); + SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); + + await GetLoggedInCli() + .Command($"cloud-save data player list --limit 2 --start \"someId\"") + .AssertNoErrors() + .DebugCommand("CloudSave_ListPlayerIds_SucceedsWithValidInput") + .ExecuteAsync(); + } + + [Test] + public async Task CloudSave_ListPlayerIds_SucceedsWithNoInput() + { + SetConfigValue("project-id", CommonKeys.ValidProjectId); + SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); + + await GetLoggedInCli() + .Command($"cloud-save data player list") + .AssertNoErrors() + .DebugCommand("CloudSave_ListPlayerIds_SucceedsWithNoInput") + .ExecuteAsync(); + } + + [Test] + public async Task CloudSave_QueryPlayerData_Default_SucceedsWithValidInput() + { + SetConfigValue("project-id", CommonKeys.ValidProjectId); + SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); + + await GetLoggedInCli() + .Command($"cloud-save data player query --body \"{JsonConvert.SerializeObject(k_ValidQueryIndexBody)}\" --visibility {k_Default}") + .AssertNoErrors() + .ExecuteAsync(); + } + + [Test] + public async Task CloudSave_QueryPlayerData_Default_NotSpecified_SucceedsWithValidInput() + { + SetConfigValue("project-id", CommonKeys.ValidProjectId); + SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); + + await GetLoggedInCli() + .Command($"cloud-save data player query --body \"{JsonConvert.SerializeObject(k_ValidQueryIndexBody)}\"") + .AssertNoErrors() + .ExecuteAsync(); + } + + [Test] + public async Task CloudSave_QueryPlayerData_Protected_SucceedsWithValidInput() + { + SetConfigValue("project-id", CommonKeys.ValidProjectId); + SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); + + await GetLoggedInCli() + .Command($"cloud-save data player query --body \"{JsonConvert.SerializeObject(k_ValidQueryIndexBody)}\" --visibility {k_Protected}") + .AssertNoErrors() + .ExecuteAsync(); + } + + [Test] + public async Task CloudSave_QueryPlayerData_Public_SucceedsWithValidInput() + { + SetConfigValue("project-id", CommonKeys.ValidProjectId); + SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); + + await GetLoggedInCli() + .Command($"cloud-save data player query --body \"{JsonConvert.SerializeObject(k_ValidQueryIndexBody)}\" --visibility {k_Protected}") + .AssertNoErrors() + .ExecuteAsync(); + } + + [Test] + public async Task CloudSave_QueryCustomData_Default_SucceedsWithValidInput() + { + SetConfigValue("project-id", CommonKeys.ValidProjectId); + SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); + + await GetLoggedInCli() + .Command($"cloud-save data custom query --body \"{JsonConvert.SerializeObject(k_ValidQueryIndexBody)}\" --visibility {k_Default}") + .AssertNoErrors() + .ExecuteAsync(); + } + + [Test] + public async Task CloudSave_QueryCustomData_NotSpecified_SucceedsWithValidInput() + { + SetConfigValue("project-id", CommonKeys.ValidProjectId); + SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); + + await GetLoggedInCli() + .Command($"cloud-save data custom query --body \"{JsonConvert.SerializeObject(k_ValidQueryIndexBody)}\"") + .AssertNoErrors() + .ExecuteAsync(); + } + + [Test] + public async Task CloudSave_QueryCustomData_Private_SucceedsWithValidInput() + { + SetConfigValue("project-id", CommonKeys.ValidProjectId); + SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); + + await GetLoggedInCli() + .Command($"cloud-save data custom query --body \"{JsonConvert.SerializeObject(k_ValidQueryIndexBody)}\" --visibility {k_Private}") + .AssertNoErrors() + .ExecuteAsync(); + } + + [Test] + public async Task CloudSave_CreatePlayerIndex_Default_SucceedsWithValidInput_UsingFields() + { + SetConfigValue("project-id", CommonKeys.ValidProjectId); + SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); + + await GetLoggedInCli() + .Command($"cloud-save data index player create --fields {JsonConvert.SerializeObject(k_ValidCreateIndexFields)} --visibility {k_Default}") + .AssertNoErrors() + .ExecuteAsync(); + } + + [Test] + public async Task CloudSave_CreatePlayerIndex_Default_SucceedsWithValidInput_UsingBody() + { + SetConfigValue("project-id", CommonKeys.ValidProjectId); + SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); + + await GetLoggedInCli() + .Command($"cloud-save data index player create --body {JsonConvert.SerializeObject(k_ValidCreateIndexBody)} --visibility {k_Default}") + .AssertNoErrors() + .ExecuteAsync(); + } + + [Test] + public async Task CloudSave_CreatePlayerIndex_Default_NotSpecified_SucceedsWithValidInput_UsingBody() + { + SetConfigValue("project-id", CommonKeys.ValidProjectId); + SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); + + await GetLoggedInCli() + .Command($"cloud-save data index player create --body {JsonConvert.SerializeObject(k_ValidCreateIndexBody)}") + .AssertNoErrors() + .ExecuteAsync(); + } + + [Test] + public async Task CloudSave_CreatePlayerIndex_Default_NotSpecified_SucceedsWithValidInput_UsingFields() + { + SetConfigValue("project-id", CommonKeys.ValidProjectId); + SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); + + await GetLoggedInCli() + .Command($"cloud-save data index player create --fields {JsonConvert.SerializeObject(k_ValidCreateIndexFields)}") + .AssertNoErrors() + .ExecuteAsync(); + } + + [Test] + public async Task CloudSave_CreatePlayerIndex_Public_SucceedsWithValidInput_UsingFields() + { + SetConfigValue("project-id", CommonKeys.ValidProjectId); + SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); + + await GetLoggedInCli() + .Command($"cloud-save data index player create --fields {JsonConvert.SerializeObject(k_ValidCreateIndexFields)} --visibility {k_Public}") + .AssertNoErrors() + .ExecuteAsync(); + } + + [Test] + public async Task CloudSave_CreatePlayerIndex_Public_SucceedsWithValidInput_UsingBody() + { + SetConfigValue("project-id", CommonKeys.ValidProjectId); + SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); + + await GetLoggedInCli() + .Command($"cloud-save data index player create --body {JsonConvert.SerializeObject(k_ValidCreateIndexBody)} --visibility {k_Public}") + .AssertNoErrors() + .ExecuteAsync(); + } + + [Test] + public async Task CloudSave_CreatePlayerIndex_Protected_SucceedsWithValidInput_UsingFields() + { + SetConfigValue("project-id", CommonKeys.ValidProjectId); + SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); + + await GetLoggedInCli() + .Command($"cloud-save data index player create --fields {JsonConvert.SerializeObject(k_ValidCreateIndexFields)} --visibility {k_Protected}") + .AssertNoErrors() + .ExecuteAsync(); + } + + [Test] + public async Task CloudSave_CreatePlayerIndex_Protected_SucceedsWithValidInput_UsingBody() + { + SetConfigValue("project-id", CommonKeys.ValidProjectId); + SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); + + await GetLoggedInCli() + .Command($"cloud-save data index player create --body {JsonConvert.SerializeObject(k_ValidCreateIndexBody)} --visibility {k_Protected}") + .AssertNoErrors() + .ExecuteAsync(); + } + + [Test] + public async Task CloudSave_CreatePlayerIndex_ThrowsWhenNotAuthenticated() + { + SetConfigValue("project-id", CommonKeys.ValidProjectId); + SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); + + await new UgsCliTestCase() + .Command($"cloud-save data index player create --visibility {k_Default}") + .AssertExitCode(ExitCode.HandledError) + .AssertStandardErrorContains(k_NotLoggedInOutput) + .ExecuteAsync(); + } + + [Test] + public async Task CloudSave_CreatePlayerIndex_ThrowsWithProjectIdMissing() + { + SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); + + await new UgsCliTestCase() + .Command($"cloud-save data index player create --visibility {k_Default}") + .AssertExitCode(ExitCode.HandledError) + .AssertStandardErrorContains(k_MissingProjectIdOutput) + .ExecuteAsync(); + } + + [Test] + public async Task CloudSave_CreatePlayerIndex_ThrowsWithEnvironmentNameMissing() + { + SetConfigValue("project-id", CommonKeys.ValidProjectId); + + await new UgsCliTestCase() + .Command($"cloud-save data index player create --visibility {k_Default}") + .AssertExitCode(ExitCode.HandledError) + .AssertStandardErrorContains(k_MissingEnvironmentNameOutput) + .ExecuteAsync(); + } + + [Test] + public async Task CloudSave_CreatePlayerIndex_ThrowsWithProjectIdEmpty() + { + SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); + + await new UgsCliTestCase() + .Command($"cloud-save data index player create --visibility {k_Default}") + .AssertExitCode(ExitCode.HandledError) + .AssertStandardErrorContains(k_MissingProjectIdOutput) + .ExecuteAsync(); + } + + [Test] + public async Task CloudSave_CreatePlayerIndex_ThrowsWithBodyMissing() + { + SetConfigValue("project-id", CommonKeys.ValidProjectId); + SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); + + await GetLoggedInCli() + .Command($"cloud-save data index player create --visibility {k_Default}") + .AssertExitCode(ExitCode.HandledError) + .AssertStandardErrorContains(k_CreateIndexBodyMissingOutput) + .ExecuteAsync(); + } + + [Test] + public async Task CloudSave_CreatePlayerIndex_ThrowsWithInvalidVisibility() + { + SetConfigValue("project-id", CommonKeys.ValidProjectId); + SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); + + string invalidVisibility = "invalidVisibility"; + + await GetLoggedInCli() + .Command($"cloud-save data index player create --fields {JsonConvert.SerializeObject(k_ValidCreateIndexFields)} --visibility {invalidVisibility}") + .AssertStandardErrorContains(k_CreatePlayerIndexInvalidVisibilityOutput) + .ExecuteAsync(); + } + + [Test] + public async Task CloudSave_CreateCustomIndex_Default_SucceedsWithValidInput_UsingFields() + { + SetConfigValue("project-id", CommonKeys.ValidProjectId); + SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); + + await GetLoggedInCli() + .Command($"cloud-save data index custom create --fields {JsonConvert.SerializeObject(k_ValidCreateIndexFields)} --visibility {k_Default}") + .AssertNoErrors() + .ExecuteAsync(); + } + + [Test] + public async Task CloudSave_CreateCustomIndex_Default_SucceedsWithValidInput_UsingBody() + { + SetConfigValue("project-id", CommonKeys.ValidProjectId); + SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); + + await GetLoggedInCli() + .Command($"cloud-save data index custom create --body {JsonConvert.SerializeObject(k_ValidCreateIndexBody)} --visibility {k_Default}") + .AssertNoErrors() + .ExecuteAsync(); + } + + [Test] + public async Task CloudSave_CreateCustomIndex_Default_NotSpecified_SucceedsWithValidInput_UsingBody() + { + SetConfigValue("project-id", CommonKeys.ValidProjectId); + SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); + + await GetLoggedInCli() + .Command($"cloud-save data index custom create --body {JsonConvert.SerializeObject(k_ValidCreateIndexBody)}") + .AssertNoErrors() + .ExecuteAsync(); + } + + [Test] + public async Task CloudSave_CreateCustomIndex_Default_NotSpecified_SucceedsWithValidInput_UsingFields() + { + SetConfigValue("project-id", CommonKeys.ValidProjectId); + SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); + + await GetLoggedInCli() + .Command($"cloud-save data index custom create --fields {JsonConvert.SerializeObject(k_ValidCreateIndexFields)}") + .AssertNoErrors() + .ExecuteAsync(); + } + + [Test] + public async Task CloudSave_CreateCustomIndex_Private_SucceedsWithValidInput_UsingFields() + { + SetConfigValue("project-id", CommonKeys.ValidProjectId); + SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); + + await GetLoggedInCli() + .Command($"cloud-save data index custom create --fields {JsonConvert.SerializeObject(k_ValidCreateIndexFields)} --visibility {k_Private}") + .AssertNoErrors() + .ExecuteAsync(); + } + + [Test] + public async Task CloudSave_CreateCustomIndex_Private_SucceedsWithValidInput_UsingBody() + { + SetConfigValue("project-id", CommonKeys.ValidProjectId); + SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); + + await GetLoggedInCli() + .Command($"cloud-save data index custom create --body {JsonConvert.SerializeObject(k_ValidCreateIndexBody)} --visibility {k_Private}") + .AssertNoErrors() + .ExecuteAsync(); + } + + [Test] + public async Task CloudSave_CreateCustomIndex_ThrowsWhenNotAuthenticated() + { + SetConfigValue("project-id", CommonKeys.ValidProjectId); + SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); + + await new UgsCliTestCase() + .Command($"cloud-save data index custom create --visibility {k_Default}") + .AssertExitCode(ExitCode.HandledError) + .AssertStandardErrorContains(k_NotLoggedInOutput) + .ExecuteAsync(); + } + + [Test] + public async Task CloudSave_CreateCustomIndex_ThrowsWithProjectIdMissing() + { + SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); + + await new UgsCliTestCase() + .Command($"cloud-save data index custom create --visibility {k_Default}") + .AssertExitCode(ExitCode.HandledError) + .AssertStandardErrorContains(k_MissingProjectIdOutput) + .ExecuteAsync(); + } + + [Test] + public async Task CloudSave_CreateCustomIndex_ThrowsWithEnvironmentNameMissing() + { + SetConfigValue("project-id", CommonKeys.ValidProjectId); + + await new UgsCliTestCase() + .Command($"cloud-save data index custom create --visibility {k_Default}") + .AssertExitCode(ExitCode.HandledError) + .AssertStandardErrorContains(k_MissingEnvironmentNameOutput) + .ExecuteAsync(); + } + + [Test] + public async Task CloudSave_CreateCustomIndex_ThrowsWithProjectIdEmpty() + { + SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); + + await new UgsCliTestCase() + .Command($"cloud-save data index custom create --visibility {k_Default}") + .AssertExitCode(ExitCode.HandledError) + .AssertStandardErrorContains(k_MissingProjectIdOutput) + .ExecuteAsync(); + } + + [Test] + public async Task CloudSave_CreateCustomIndex_ThrowsWithBodyMissing() + { + SetConfigValue("project-id", CommonKeys.ValidProjectId); + SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); + + await GetLoggedInCli() + .Command($"cloud-save data index custom create --visibility {k_Default}") + .AssertExitCode(ExitCode.HandledError) + .AssertStandardErrorContains(k_CreateIndexBodyMissingOutput) + .ExecuteAsync(); + } + + [Test] + public async Task CloudSave_CreateCustomIndex_ThrowsWithInvalidVisibility() + { + SetConfigValue("project-id", CommonKeys.ValidProjectId); + SetConfigValue("environment-name", CommonKeys.ValidEnvironmentName); + + string invalidVisibility = "invalidVisibility"; + + await GetLoggedInCli() + .Command($"cloud-save data index custom create --fields {JsonConvert.SerializeObject(k_ValidCreateIndexFields)} --visibility {invalidVisibility}") + .AssertStandardErrorContains(k_CreateCustomIndexInvalidVisibilityOutput) + .ExecuteAsync(); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/ConfigTests/ConfigTests.cs b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/ConfigTests/ConfigTests.cs index a78b472..f78e725 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/ConfigTests/ConfigTests.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/ConfigTests/ConfigTests.cs @@ -131,11 +131,16 @@ public async Task ConfigDeleteSpecificKeySavesToConfigFile() { const string expectedError = "Specified keys were deleted from local configuration."; SetConfigValue(Keys.ConfigKeys.EnvironmentName, "test-123"); + SetConfigValue(Keys.ConfigKeys.ProjectId, "00000000-0000-0000-0000-000000000000"); await new UgsCliTestCase() - .Command($"config delete -k {Keys.ConfigKeys.EnvironmentName} -f") + .Command($"config delete -k {Keys.ConfigKeys.EnvironmentName} {Keys.ConfigKeys.ProjectId} -f") .AssertStandardErrorContains(expectedError) - .WaitForExit(() => AssertConfigValue(Keys.ConfigKeys.EnvironmentName, null)) + .WaitForExit(() => + { + AssertConfigValue(Keys.ConfigKeys.EnvironmentName, null); + AssertConfigValue(Keys.ConfigKeys.ProjectId, null); + }) .ExecuteAsync(); } diff --git a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/Unity.Services.Cli.IntegrationTest.csproj b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/Unity.Services.Cli.IntegrationTest.csproj index 85dbb7e..f09a9a5 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/Unity.Services.Cli.IntegrationTest.csproj +++ b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/Unity.Services.Cli.IntegrationTest.csproj @@ -16,7 +16,7 @@ - + @@ -27,6 +27,7 @@ + \ No newline at end of file diff --git a/Unity.Services.Cli/Unity.Services.Cli.Matchmaker.UnitTest/AdminApiClientUnitTests.cs b/Unity.Services.Cli/Unity.Services.Cli.Matchmaker.UnitTest/AdminApiClientUnitTests.cs new file mode 100644 index 0000000..71863e4 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Matchmaker.UnitTest/AdminApiClientUnitTests.cs @@ -0,0 +1,319 @@ +using System.Reflection; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using Newtonsoft.Json; +using NUnit.Framework; +using Unity.Services.Cli.Common.Networking; +using Unity.Services.Cli.GameServerHosting.Service; +using Unity.Services.Cli.Matchmaker.Parser; +using Unity.Services.Cli.Matchmaker.Service; +using Unity.Services.Cli.Matchmaker.UnitTest.SampleConfigs; +using Unity.Services.Cli.ServiceAccountAuthentication; +using Unity.Services.Matchmaker.Authoring.Core.ConfigApi; +using Core = Unity.Services.Matchmaker.Authoring.Core.Model; +using Generated = Unity.Services.Gateway.MatchmakerAdminApiV3.Generated.Model; + + +namespace Unity.Services.Cli.Matchmaker.UnitTest; + +[TestFixture] +class AdminApiClientUnitTests +{ + Mock m_MockSaAuthService = null!; + + [SetUp] + public void Setup() + { + var types = new List + { + typeof(AdminApiTargetEndpoint).GetTypeInfo(), + typeof(UnityServicesGatewayEndpoints).GetTypeInfo(), + }; + EndpointHelper.InitializeNetworkTargetEndpoints(types); + m_MockSaAuthService = new Mock(); + m_MockSaAuthService.Setup(x => x.GetAccessTokenAsync(default)).Returns(Task.FromResult("token")); + var configClient = new Mock(); + new ServiceCollection() + .AddSingleton(m_MockSaAuthService.Object) + .AddSingleton(configClient.Object) + .BuildServiceProvider(); + } + + [Test] + public async Task GetEnvironmentConfigNotFound() + { + // Setup + var multiplaySampleConfig = new MultiplaySampleConfig(); + var gshService = new Mock(); + gshService.Setup(f => f.FleetsApi.ListFleets(It.IsAny(), It.IsAny(), default)).Returns(multiplaySampleConfig.RemoteFleets); + var configService = new Mock(); + configService + .Setup(x => x.GetEnvironmentConfig(default)) + .Returns(Task.FromResult((false, new Generated.EnvironmentConfig()))); + + var client = new AdminApiClient.MatchmakerAdminClient(configService.Object, gshService.Object); + + // Test + await client.Initialize(Guid.NewGuid().ToString(), Guid.NewGuid().ToString(), default); + var (exist, _) = await client.GetEnvironmentConfig(default); + + // Assert + Assert.That(exist, Is.EqualTo(false)); + } + + [Test] + public async Task GetEnvironmentConfig() + { + // Setup + var multiplaySampleConfig = new MultiplaySampleConfig(); + var removeEnvConfig = new Generated.EnvironmentConfig() + { + Enabled = true, + DefaultQueueName = "Test" + }; + var gshService = new Mock(); + gshService.Setup(f => f.FleetsApi.ListFleets(It.IsAny(), It.IsAny(), default)).Returns(multiplaySampleConfig.RemoteFleets); + var configService = new Mock(); + configService + .Setup(x => x.GetEnvironmentConfig(default)) + .Returns(Task.FromResult((true, removeEnvConfig))); + var expectedConfig = new Core.EnvironmentConfig + { + Type = Core.IMatchmakerConfig.ConfigType.EnvironmentConfig, + Enabled = true, + DefaultQueueName = new Core.QueueName("Test") + }; + + var client = new AdminApiClient.MatchmakerAdminClient(configService.Object, gshService.Object); + + // Test + await client.Initialize(Guid.NewGuid().ToString(), Guid.NewGuid().ToString(), default); + var actualConfig = await client.GetEnvironmentConfig(default); + + // Assert + var actualJson = JsonConvert.SerializeObject(actualConfig.Item2, MatchmakerConfigParser.JsonSerializerSettings); + var expectedJson = JsonConvert.SerializeObject(expectedConfig, MatchmakerConfigParser.JsonSerializerSettings); + Assert.That(actualJson, Is.EqualTo(expectedJson)); + } + + [Test] + public async Task UpsertEnvironmentConfig() + { + // Setup + var multiplaySampleConfig = new MultiplaySampleConfig(); + var gshService = new Mock(); + gshService.Setup(f => f.FleetsApi.ListFleets(It.IsAny(), It.IsAny(), default)).Returns(multiplaySampleConfig.RemoteFleets); + var configService = new Mock(); + configService + .Setup(x => x.UpsertEnvironmentConfig(It.IsAny(), false, default)) + .Returns(Task.FromResult(new List() { new() { ResultCode = "MockedFailedValidation", Message = "Mocked failed validation" } })); + var localConfig = new Core.EnvironmentConfig + { + Enabled = true, + DefaultQueueName = new Core.QueueName("Test") + }; + var client = new AdminApiClient.MatchmakerAdminClient(configService.Object, gshService.Object); + await client.Initialize(Guid.NewGuid().ToString(), Guid.NewGuid().ToString(), default); + var errors = await client.UpsertEnvironmentConfig(localConfig, false, default); + + // Assert + Assert.That(configService.Invocations.Count, Is.EqualTo(2)); + var actualEnvConfig = JsonConvert.SerializeObject(configService.Invocations[1].Arguments[0]); + var expectedConfig = JsonConvert.SerializeObject(new Generated.EnvironmentConfig() + { + Enabled = true, + DefaultQueueName = "Test" + }); + Assert.That(actualEnvConfig, Is.EqualTo(expectedConfig)); + Assert.That(errors.Count, Is.EqualTo(1)); + Assert.That(errors[0].ResultCode, Is.EqualTo("MockedFailedValidation")); + } + + + [Test] + public async Task ListQueues() + { + // Setup + var multiplaySampleConfig = new MultiplaySampleConfig(); + var coreSampleConfig = new CoreSampleConfig(); + var generatedSampleConfig = new GeneratedSampleConfig(); + var gshService = new Mock(); + gshService.Setup(f => f.FleetsApi.ListFleets(It.IsAny(), It.IsAny(), default)).Returns(multiplaySampleConfig.RemoteFleets); + var configService = new Mock(); + configService + .Setup(x => x.ListQueues(default)) + .Returns(Task.FromResult(new List() + { + generatedSampleConfig.QueueConfig, + generatedSampleConfig.EmptyQueueConfig + })); + var expectedQueueConfigs = new List() + { + coreSampleConfig.QueueConfig, + coreSampleConfig.EmptyQueueConfig + }; + + var client = new AdminApiClient.MatchmakerAdminClient(configService.Object, gshService.Object); + + // Test + await client.Initialize(Guid.NewGuid().ToString(), Guid.NewGuid().ToString(), default); + var actualConfig = await client.ListQueues(default); + + // Assert + Assert.That(actualConfig.Count, Is.EqualTo(2)); + Assert.That(actualConfig[0].Item2, Is.Empty); + Assert.That(actualConfig[1].Item2, Is.Empty); + var actualJson = JsonConvert.SerializeObject(actualConfig[0].Item1, MatchmakerConfigParser.JsonSerializerSettings); + var expectedJson = JsonConvert.SerializeObject(expectedQueueConfigs[0], MatchmakerConfigParser.JsonSerializerSettings); + Assert.That(expectedJson, Is.EqualTo(actualJson)); + actualJson = JsonConvert.SerializeObject(actualConfig[1].Item1, MatchmakerConfigParser.JsonSerializerSettings); + expectedJson = JsonConvert.SerializeObject(expectedQueueConfigs[1], MatchmakerConfigParser.JsonSerializerSettings); + Assert.That(expectedJson, Is.EqualTo(actualJson)); + } + + [Test] + [TestCase(true)] + [TestCase(false)] + public async Task UpsertQueue(bool emptyQueue) + { + // Setup + var multiplaySampleConfig = new MultiplaySampleConfig(); + var coreSampleConfig = new CoreSampleConfig(); + var generatedSampleConfig = new GeneratedSampleConfig(); + var gshService = new Mock(); + gshService.Setup(f => f.FleetsApi.ListFleets(It.IsAny(), It.IsAny(), default)).Returns(multiplaySampleConfig.RemoteFleets); + var configService = new Mock(); + configService + .Setup(x => x.UpsertQueueConfig(It.IsAny(), false, default)) + .Returns(Task.FromResult(new List() { new() { ResultCode = "MockedFailedValidation", Message = "Mocked failed validation" } })); + + var client = new AdminApiClient.MatchmakerAdminClient(configService.Object, gshService.Object); + + // Test + await client.Initialize(Guid.NewGuid().ToString(), Guid.NewGuid().ToString(), default); + var errors = await client.UpsertQueue(emptyQueue ? coreSampleConfig.EmptyQueueConfig : coreSampleConfig.QueueConfig, multiplaySampleConfig.LocalResources, false); + + // Assert + Assert.That(configService.Invocations.Count, Is.EqualTo(2)); + var that = configService.Invocations[1].Arguments[0]; + var actualEnvConfig = JsonConvert.SerializeObject(that, MatchmakerConfigParser.JsonSerializerSettings); + var expectedConfig = JsonConvert.SerializeObject(emptyQueue ? generatedSampleConfig.EmptyQueueConfig : generatedSampleConfig.QueueConfig, MatchmakerConfigParser.JsonSerializerSettings); + Assert.That(actualEnvConfig, Is.EqualTo(expectedConfig)); + Assert.That(errors.Count, Is.EqualTo(1)); + Assert.That(errors[0].ResultCode, Is.EqualTo("MockedFailedValidation")); + } + + [Test] + public async Task UpsertQueueInvalidMultiplayConfig() + { + // Setup + var multiplaySampleConfig = new MultiplaySampleConfig(); + var coreSampleConfig = new CoreSampleConfig(); + var gshService = new Mock(); + gshService.Setup(f => f.FleetsApi.ListFleets(It.IsAny(), It.IsAny(), default)).Returns(multiplaySampleConfig.RemoteFleets); + var client = new AdminApiClient.MatchmakerAdminClient(new Mock().Object, gshService.Object); + var queue = coreSampleConfig.QueueConfig; + await client.Initialize(Guid.NewGuid().ToString(), Guid.NewGuid().ToString(), default); + + // Test + var multiplayConfig = (Core.MultiplayConfig)queue.DefaultPool.MatchHosting; + multiplayConfig.DefaultQoSRegionName = "Invalid"; + var errors = await client.UpsertQueue(coreSampleConfig.QueueConfig, multiplaySampleConfig.LocalResources, false); + + // Assert + Assert.That(errors.Count, Is.EqualTo(1)); + Assert.That(errors[0].ResultCode, Is.EqualTo("InvalidDefaultQoSRegion")); + + // Test + multiplayConfig.BuildConfigurationName = "Invalid"; + errors = await client.UpsertQueue(coreSampleConfig.QueueConfig, multiplaySampleConfig.LocalResources, false); + + // Assert + Assert.That(errors.Count, Is.EqualTo(1)); + Assert.That(errors[0].ResultCode, Is.EqualTo("InvalidBuildConfigurationName")); + + // Test + multiplayConfig.FleetName = "Invalid"; + errors = await client.UpsertQueue(coreSampleConfig.QueueConfig, multiplaySampleConfig.LocalResources, false); + + // Assert + Assert.That(errors.Count, Is.EqualTo(1)); + Assert.That(errors[0].ResultCode, Is.EqualTo("InvalidMultiplayFleetName")); + } + + + [Test] + public async Task GetQueueInvalidMultiplayConfig() + { + // Setup + var multiplaySampleConfig = new MultiplaySampleConfig(); + var generatedSampleConfig = new GeneratedSampleConfig(); + + var gshService = new Mock(); + var configService = new Mock(); + var multiplayConfig = multiplaySampleConfig.RemoteFleets; + configService.Setup(f => f.ListQueues(default)) + .ReturnsAsync(new List() { generatedSampleConfig.QueueConfig }); + var client = new AdminApiClient.MatchmakerAdminClient(configService.Object, gshService.Object); + + // Setup + multiplayConfig[0].Regions[0].RegionID = new Guid(); + gshService.Setup(f => f.FleetsApi.ListFleets(It.IsAny(), It.IsAny(), default)).Returns(multiplayConfig); + await client.Initialize(Guid.NewGuid().ToString(), Guid.NewGuid().ToString()); + + // Test + var response = await client.ListQueues(); + + // Assert + Assert.That(response.Count, Is.EqualTo(1)); + Assert.That(response[0].Item2.Count, Is.EqualTo(1)); + Assert.That(response[0].Item2[0].ResultCode, Is.EqualTo("InvalidDefaultQoSRegion")); + + // Setup + multiplayConfig[0].BuildConfigurations[0].Id = 0; + gshService.Setup(f => f.FleetsApi.ListFleets(It.IsAny(), It.IsAny(), default)).Returns(multiplayConfig); + await client.Initialize(Guid.NewGuid().ToString(), Guid.NewGuid().ToString()); + + // Test + response = await client.ListQueues(); + + // Assert + Assert.That(response.Count, Is.EqualTo(1)); + Assert.That(response[0].Item2.Count, Is.EqualTo(1)); + Assert.That(response[0].Item2[0].ResultCode, Is.EqualTo("InvalidBuildConfigurationId")); + + // Setup + multiplayConfig[0].Id = new Guid(); + gshService.Setup(f => f.FleetsApi.ListFleets(It.IsAny(), It.IsAny(), default)).Returns(multiplayConfig); + await client.Initialize(Guid.NewGuid().ToString(), Guid.NewGuid().ToString()); + + // Test + response = await client.ListQueues(); + + // Assert + Assert.That(response.Count, Is.EqualTo(1)); + Assert.That(response[0].Item2.Count, Is.EqualTo(1)); + Assert.That(response[0].Item2[0].ResultCode, Is.EqualTo("InvalidMultiplayFleetId")); + } + + [Test] + public async Task DeleteQueue() + { + // Setup + var configService = new Mock(); + configService + .Setup(x => x.DeleteQueue("ToDelete", false, default)) + .Returns(Task.FromResult(new List())); + + var client = new AdminApiClient.MatchmakerAdminClient(configService.Object, new Mock().Object); + + // Test + await client.DeleteQueue(new Core.QueueName("ToDelete"), false); + + // Assert + Assert.That(configService.Invocations.Count, Is.EqualTo(1)); + var name = configService.Invocations[0].Arguments[0]; + Assert.That(name, Is.EqualTo("ToDelete")); + } + +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Matchmaker.UnitTest/ConfigParserUnitTests.cs b/Unity.Services.Cli/Unity.Services.Cli.Matchmaker.UnitTest/ConfigParserUnitTests.cs new file mode 100644 index 0000000..0245909 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Matchmaker.UnitTest/ConfigParserUnitTests.cs @@ -0,0 +1,276 @@ +using KellermanSoftware.CompareNetObjects; +using Moq; +using Newtonsoft.Json; +using NUnit.Framework; +using Unity.Services.Cli.Matchmaker.Parser; +using Unity.Services.Cli.Matchmaker.Service; +using Unity.Services.Cli.Matchmaker.UnitTest.SampleConfigs; +using Unity.Services.Matchmaker.Authoring.Core.IO; +using Unity.Services.Matchmaker.Authoring.Core.Model; +using Unity.Services.Matchmaker.Authoring.Core.Parser; + +namespace Unity.Services.Cli.Matchmaker.UnitTest; + +[TestFixture] +class ConfigParserUnitTests +{ + CompareLogic m_CompareLogic = new CompareLogic(); + + [SetUp] + public void Setup() + { + m_CompareLogic.Config.ComparePrivateProperties = true; + m_CompareLogic.Config.ComparePrivateFields = true; + } + + [Test] + public async Task EnvironmentConfigParse() + { + // Setup + var coreSampleConfig = new CoreSampleConfig(); + var mockFileSystem = new Mock(); + mockFileSystem.Setup(x => x.GetFileName("path/to_file.mme")).Returns("to_file"); + mockFileSystem.Setup(x => x.ReadAllText("path/to_file.mme", default)).ReturnsAsync(JsonSampleConfigLoader.EnvironmentConfig); + var configParser = new MatchmakerConfigParser(mockFileSystem.Object); + + // Test + var result = await configParser.Parse(new[] { "path/to_file.mme" }, default); + + // Assert + Assert.That(result.failed.Count, Is.EqualTo(0)); + Assert.That(result.parsed.Count, Is.EqualTo(1)); + var actual = (EnvironmentConfig)result.parsed[0].Content; + var compResult = m_CompareLogic.Compare(coreSampleConfig.EnvironmentConfig, actual); + Assert.IsTrue(compResult.AreEqual, compResult.DifferencesString); + } + + [Test] + public async Task EnvironmentConfigSerialize() + { + // Setup + var coreSampleConfig = new CoreSampleConfig(); + var mockFileSystem = new Mock(); + mockFileSystem.Setup(x => x.WriteAllText("path/to_file.mme", It.IsAny(), default)); + var configParser = new MatchmakerConfigParser(mockFileSystem.Object); + + // Test + var result = await configParser.SerializeToFile(coreSampleConfig.EnvironmentConfig, "path/to_file.mme", default); + + // Assert + Assert.IsTrue(result.Item1); + Assert.IsEmpty(result.Item2); + Assert.That(mockFileSystem.Invocations.Count, Is.EqualTo(2)); + Assert.That(mockFileSystem.Invocations[1].Arguments.Count, Is.EqualTo(3)); + var actualJson = (string)mockFileSystem.Invocations[1].Arguments[1]; + Assert.That(actualJson, Is.EqualTo(JsonSampleConfigLoader.EnvironmentConfig)); + } + + [Test] + public async Task QueueConfigParse() + { + // Setup + var coreSampleConfig = new CoreSampleConfig(); + var mockFileSystem = new Mock(); + mockFileSystem.Setup(x => x.GetFileName("path/queue.mmq")).Returns("queue"); + mockFileSystem.Setup(x => x.GetFileName("path/empty_queue.mmq")).Returns("empty_queue"); + mockFileSystem.Setup(x => x.ReadAllText("path/queue.mmq", default)).ReturnsAsync(JsonSampleConfigLoader.QueueConfig); + mockFileSystem.Setup(x => x.ReadAllText("path/empty_queue.mmq", default)).ReturnsAsync(JsonSampleConfigLoader.EmptyQueueConfig); + var configParser = new MatchmakerConfigParser(mockFileSystem.Object); + + // Test + var result = await configParser.Parse(new[] { "path/queue.mmq", "path/empty_queue.mmq" }, default); + + // Assert + Assert.That(result.failed.Count, Is.EqualTo(0)); + Assert.That(result.parsed.Count, Is.EqualTo(2)); + var actual = (QueueConfig)result.parsed[0].Content; + var compResult = m_CompareLogic.Compare(coreSampleConfig.QueueConfig, actual); + Assert.IsTrue(compResult.AreEqual, compResult.DifferencesString); + actual = (QueueConfig)result.parsed[1].Content; + compResult = m_CompareLogic.Compare(coreSampleConfig.EmptyQueueConfig, actual); + Assert.IsTrue(compResult.AreEqual, compResult.DifferencesString); + } + + + [Test] + [TestCase(true)] + [TestCase(false)] + public async Task QueueConfigSerialize(bool emptyConfig) + { + // Setup + var coreSampleConfig = new CoreSampleConfig(); + var mockFileSystem = new Mock(); + mockFileSystem.Setup(x => x.WriteAllText("path/to_file.mmq", It.IsAny(), default)); + var configParser = new MatchmakerConfigParser(mockFileSystem.Object); + + // Test + var result = await configParser.SerializeToFile( + emptyConfig ? coreSampleConfig.EmptyQueueConfig : coreSampleConfig.QueueConfig + , "path/to_file.mmq", + default); + + // Assert + Assert.IsTrue(result.Item1); + Assert.IsEmpty(result.Item2); + Assert.That(mockFileSystem.Invocations.Count, Is.EqualTo(2)); + Assert.That(mockFileSystem.Invocations[1].Arguments.Count, Is.EqualTo(3)); + var actualJson = (string)mockFileSystem.Invocations[1].Arguments[1]; + Assert.That( + actualJson, + emptyConfig + ? Is.EqualTo(JsonSampleConfigLoader.EmptyQueueConfig) + : Is.EqualTo(JsonSampleConfigLoader.QueueConfig)); + } + + [Test] + public async Task DeepEqualityCheck() + { + // Setup + var mockFileSystem = new Mock(); + mockFileSystem.Setup(x => x.ReadAllText(It.IsAny(), default)).ReturnsAsync((string path, CancellationToken _) => + { + if (path.StartsWith("queue")) + return JsonSampleConfigLoader.QueueConfig; + return JsonSampleConfigLoader.EnvironmentConfig; + } + ); + var configParser = new MatchmakerConfigParser(mockFileSystem.Object); + + var res = await configParser.Parse(new List { "queue.mmq", "env.mme", }, default); + var res2 = await configParser.Parse(new List { "queueCopy.mmq", "envCopy.mme" }, default); + + res.failed.AddRange(res2.failed); + res.parsed.AddRange(res2.parsed); + + // Test + Assert.That(res.failed.Count, Is.EqualTo(0)); + Assert.That(res.parsed.Count, Is.EqualTo(4)); + + var queue = (QueueConfig)res.parsed[0].Content; + var env = (EnvironmentConfig)res.parsed[1].Content; + var queueCopy = (QueueConfig)res.parsed[2].Content; + var envCopy = (EnvironmentConfig)res.parsed[3].Content; + + // Test + Assert.IsTrue(configParser.IsDeepEqual(queue, queueCopy)); + Assert.IsTrue(configParser.IsDeepEqual(env, envCopy)); + + // Modify copies + queueCopy.MaxPlayersPerTicket = 5; + envCopy.DefaultQueueName = new QueueName("newQueue"); + + // Test + Assert.IsFalse(configParser.IsDeepEqual(queue, queueCopy)); + Assert.IsFalse(configParser.IsDeepEqual(env, envCopy)); + } + + [Test] + public async Task ParseDuplicateFails() + { + // Setup + var mockFileSystem = new Mock(); + mockFileSystem.Setup(x => x.ReadAllText(It.IsAny(), default)).ReturnsAsync((string path, CancellationToken _) => + { + if (path.StartsWith("queue")) + return JsonSampleConfigLoader.QueueConfig; + return JsonSampleConfigLoader.EnvironmentConfig; + } + ); + var configParser = new MatchmakerConfigParser(mockFileSystem.Object); + + var res = await configParser.Parse(new List { "queue.mmq", "env.mme", "queueCopy.mmq", "envCopy.mme" }, default); + + // Test + Assert.That(res.failed.Count, Is.EqualTo(2)); + Assert.That(res.parsed.Count, Is.EqualTo(2)); + Assert.That(res.failed.Select(f => f.Status.MessageDetail), + Is.EquivalentTo(new[] + { + "Multiple environment config files found in envCopy.mme", + "Multiple queue config files named DefaultQueueTest found in queueCopy.mmq" + })); + } + + [Test] + public void QueueConfigTemplate() + { + // Setup + var template = new QueueConfigTemplate(); + + // Assert + Assert.AreEqual(JsonSampleConfigLoader.TemplateQueueConfig, template.FileBodyText); + } + + [Test] + public async Task ParseInvalidJson() + { + // Setup + var invalidJson = "NotValid"; + var mockFileSystem = new Mock(); + mockFileSystem.Setup(x => x.GetFileName("path/to_file.mmq")).Returns("to_file"); + mockFileSystem.Setup(x => x.ReadAllText("path/to_file.mmq", default)).ReturnsAsync(invalidJson); + var configParser = new MatchmakerConfigParser(mockFileSystem.Object); + + // Test + var result = await configParser.Parse(new[] { "path/to_file.mmq" }, default); + + // Assert + Assert.That(result.failed.Count, Is.EqualTo(1)); + Assert.That(result.parsed.Count, Is.EqualTo(0)); + Assert.That(result.failed[0].Status.Message, Is.EqualTo("Invalid json in file path/to_file.mmq")); + } + + [Test] + public async Task ParseInvalidPath() + { + // Setup + var mockFileSystem = new Mock(); + mockFileSystem.Setup(x => x.GetFileName("path/to_file.mmq")).Returns("to_file"); + mockFileSystem.Setup(x => x.ReadAllText("path/to_file.mmq", default)).Throws(new FileSystemException("path/to_file.mmq", FileSystemException.Action.Read, "File not found")); + var configParser = new MatchmakerConfigParser(mockFileSystem.Object); + + // Test + var result = await configParser.Parse(new[] { "path/to_file.mmq" }, default); + + // Assert + Assert.That(result.failed.Count, Is.EqualTo(1)); + Assert.That(result.parsed.Count, Is.EqualTo(0)); + Assert.That(result.failed[0].Status.MessageDetail, Is.EqualTo("Error trying to read file at path/to_file.mmq : File not found")); + } + + [Test] + public async Task ParseEmptyConfig() + { + // Setup + var invalidJson = ""; + var mockFileSystem = new Mock(); + mockFileSystem.Setup(x => x.GetFileName("path/to_file.mmq")).Returns("to_file"); + mockFileSystem.Setup(x => x.ReadAllText("path/to_file.mmq", default)).ReturnsAsync(invalidJson); + var configParser = new MatchmakerConfigParser(mockFileSystem.Object); + + // Test + var result = await configParser.Parse(new[] { "path/to_file.mmq" }, default); + + // Assert + Assert.That(result.failed.Count, Is.EqualTo(1)); + Assert.That(result.parsed.Count, Is.EqualTo(0)); + Assert.That(result.failed[0].Status.MessageDetail, Is.EqualTo("Is the file empty ?")); + } + + [Test] + public async Task ConfigSerializeException() + { + // Setup + var coreSampleConfig = new CoreSampleConfig(); + var mockFileSystem = new Mock(); + mockFileSystem.Setup(x => x.WriteAllText("path/to_file.mme", It.IsAny(), default)) + .Throws(new FileSystemException("path/to_file.mme", FileSystemException.Action.Write, "File not found")); + var configParser = new MatchmakerConfigParser(mockFileSystem.Object); + + // Test + var (_, error) = await configParser.SerializeToFile(coreSampleConfig.EnvironmentConfig, "path/to_file.mme", default); + + // Assert + Assert.That(error, Is.EqualTo(error)); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Matchmaker.UnitTest/DeploymentServiceUnitTests.cs b/Unity.Services.Cli/Unity.Services.Cli.Matchmaker.UnitTest/DeploymentServiceUnitTests.cs new file mode 100644 index 0000000..d927a05 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Matchmaker.UnitTest/DeploymentServiceUnitTests.cs @@ -0,0 +1,74 @@ +using NUnit.Framework; +using Moq; +using Unity.Services.Cli.Authoring.Input; +using Unity.Services.Cli.Matchmaker.Service; +using Unity.Services.Matchmaker.Authoring.Core.ConfigApi; +using Unity.Services.Matchmaker.Authoring.Core.Deploy; +using Unity.Services.Matchmaker.Authoring.Core.Model; +using Unity.Services.Cli.GameServerHosting.Services; +using Unity.Services.Cli.Matchmaker.UnitTest.SampleConfigs; + +namespace Unity.Services.Cli.Matchmaker.UnitTest; + +[TestFixture] +public class DeploymentServiceUnitTests +{ + [Test] + public async Task Deploy_ShouldReturnDeploymentResult_WhenCalledWithValidParameters() + { + // Arrange + var multiplaySampleConfig = new MultiplaySampleConfig(); + var resource = new MatchmakerConfigResource() { Name = "Test", Path = "TestPath.mmq" }; + var mockClient = new Mock(); + var mockGshConfigLoader = new Mock(); + mockGshConfigLoader.Setup(f => f.LoadAndValidateAsync(It.IsAny>(), default)).Returns(Task.FromResult(multiplaySampleConfig.LocalConfigs)); + var mockDeploymentHandler = new Mock(); + mockDeploymentHandler.Setup(m => m.DeployAsync(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new DeployResult() + { + Created = { resource }, + Updated = { resource }, + Authored = { resource }, + Failed = { resource }, + Deleted = { resource } + }); + var service = new MatchmakerDeploymentService(mockClient.Object, mockDeploymentHandler.Object, mockGshConfigLoader.Object); + var deployInput = new DeployInput(); + var filePaths = new List { "test.mmq" }; + var projectId = "testProjectId"; + var environmentId = "testEnvironmentId"; + var cancellationToken = new CancellationToken(); + + // Act + var result = await service.Deploy(deployInput, filePaths, projectId, environmentId, null, cancellationToken); + + // Assert + Assert.NotNull(result); + Assert.That(service.ServiceName, Is.EqualTo("matchmaker")); + Assert.That(service.ServiceType, Is.EqualTo("Matchmaker")); + Assert.That(service.FileExtensions, Is.EqualTo(new[] { ".mme", ".mmq" })); + } + + [Test] + public void Deploy_ShouldThrowMatchmakerException_WhenAbortMessageIsNotEmpty() + { + // Arrange + var multiplaySampleConfig = new MultiplaySampleConfig(); + var mockClient = new Mock(); + var mockGshConfigLoader = new Mock(); + mockGshConfigLoader.Setup(f => f.LoadAndValidateAsync(It.IsAny>(), default)).Returns(Task.FromResult(multiplaySampleConfig.LocalConfigs)); + + var mockDeploymentHandler = new Mock(); + mockDeploymentHandler.Setup(m => m.DeployAsync(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new DeployResult { AbortMessage = "Abort" }); + var service = new MatchmakerDeploymentService(mockClient.Object, mockDeploymentHandler.Object, mockGshConfigLoader.Object); + var deployInput = new DeployInput(); + var filePaths = new List { "test.mmq" }; + var projectId = "testProjectId"; + var environmentId = "testEnvironmentId"; + var cancellationToken = new CancellationToken(); + + // Act & Assert + Assert.ThrowsAsync(async () => await service.Deploy(deployInput, filePaths, projectId, environmentId, null, cancellationToken)); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Matchmaker.UnitTest/FetchServiceUnitTests.cs b/Unity.Services.Cli/Unity.Services.Cli.Matchmaker.UnitTest/FetchServiceUnitTests.cs new file mode 100644 index 0000000..049c377 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Matchmaker.UnitTest/FetchServiceUnitTests.cs @@ -0,0 +1,62 @@ +using NUnit.Framework; +using Moq; +using Spectre.Console; +using Unity.Services.Cli.Authoring.Input; +using Unity.Services.Cli.Matchmaker.Service; +using Unity.Services.Matchmaker.Authoring.Core.ConfigApi; +using Unity.Services.Matchmaker.Authoring.Core.Fetch; +using Unity.Services.Matchmaker.Authoring.Core.Model; +using FetchResult = Unity.Services.Matchmaker.Authoring.Core.Fetch.FetchResult; + +namespace Unity.Services.Cli.Matchmaker.UnitTest; + +[TestFixture] +public class MatchmakerFetchServiceUnitTests +{ + [Test] + public async Task FetchAsync_ShouldReturnFetchResult_WhenCalledWithValidParameters() + { + var resource = new MatchmakerConfigResource() { Name = "Test", Path = "TestPath" }; + var mockClient = new Mock(); + var mockFetchHandler = new Mock(); + mockFetchHandler.Setup(m => m.FetchAsync(It.IsAny(), It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new FetchResult() + { + Created = { resource }, + Updated = { resource }, + Authored = { resource }, + Failed = { resource }, + Deleted = { resource } + }); + var service = new MatchmakerFetchService(mockClient.Object, mockFetchHandler.Object); + var fetchInput = new FetchInput(); + var filePaths = new List { "test.mme" }; + var projectId = "testProjectId"; + var environmentId = "testEnvironmentId"; + var cancellationToken = new CancellationToken(); + + var result = await service.FetchAsync(fetchInput, filePaths, projectId, environmentId, null, cancellationToken); + + Assert.NotNull(result); + Assert.That(service.ServiceName, Is.EqualTo("matchmaker")); + Assert.That(service.ServiceType, Is.EqualTo("Matchmaker")); + Assert.That(service.FileExtensions, Is.EqualTo(new[] { ".mme", ".mmq" })); + } + + [Test] + public void FetchAsync_ShouldThrowMatchmakerException_WhenAbortMessageIsNotEmpty() + { + var mockClient = new Mock(); + var mockFetchHandler = new Mock(); + mockFetchHandler.Setup(m => m.FetchAsync(It.IsAny(), It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new FetchResult { AbortMessage = "Abort" }); + var service = new MatchmakerFetchService(mockClient.Object, mockFetchHandler.Object); + var fetchInput = new FetchInput(); + var filePaths = new List { "test.mm" }; + var projectId = "testProjectId"; + var environmentId = "testEnvironmentId"; + var cancellationToken = new CancellationToken(); + + Assert.ThrowsAsync(async () => await service.FetchAsync(fetchInput, filePaths, projectId, environmentId, null, cancellationToken)); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Matchmaker.UnitTest/MatchmakerModuleTest.cs b/Unity.Services.Cli/Unity.Services.Cli.Matchmaker.UnitTest/MatchmakerModuleTest.cs new file mode 100644 index 0000000..d2b7cfb --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Matchmaker.UnitTest/MatchmakerModuleTest.cs @@ -0,0 +1,57 @@ +using System.CommandLine.Builder; +using System.Reflection; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; +using Unity.Services.Cli.Authoring.Service; +using Unity.Services.Cli.Common; +using Unity.Services.Cli.Common.Networking; +using Unity.Services.Cli.Matchmaker.Service; +using Unity.Services.Cli.TestUtils; +using Unity.Services.Gateway.MatchmakerAdminApiV3.Generated.Api; +using Unity.Services.Matchmaker.Authoring.Core.ConfigApi; +using Unity.Services.Matchmaker.Authoring.Core.Deploy; +using Unity.Services.Matchmaker.Authoring.Core.Fetch; +using Unity.Services.Matchmaker.Authoring.Core.IO; +using Unity.Services.Matchmaker.Authoring.Core.Parser; + +namespace Unity.Services.Cli.Matchmaker.UnitTest; + +[TestFixture] +public class MatchmakerModuleTest +{ + static readonly MatchmakerModule k_MatchmakerModule = new(); + + [TestCase(typeof(IMatchmakerAdminApi))] + [TestCase(typeof(IMatchmakerConfigParser))] + [TestCase(typeof(IConfigApiClient))] + [TestCase(typeof(IMatchmakerDeployHandler))] + [TestCase(typeof(IMatchmakerFetchHandler))] + [TestCase(typeof(IDeepEqualityComparer))] + [TestCase(typeof(IMatchmakerService))] + [TestCase(typeof(IFileSystem))] + [TestCase(typeof(IDeploymentService))] + [TestCase(typeof(IFetchService))] + public void ConfigureMatchmakerRegistersExpectedServices(Type serviceType) + { + EndpointHelper.InitializeNetworkTargetEndpoints(new[] + { + typeof(UnityServicesGatewayEndpoints).GetTypeInfo(), + typeof(AdminApiTargetEndpoint).GetTypeInfo() + }); + var services = new List(); + var hostBuilder = TestsHelper.CreateAndSetupMockHostBuilder(services); + hostBuilder.ConfigureServices(MatchmakerModule.RegisterServices); + Assert.That(services.FirstOrDefault(c => c.ServiceType == serviceType), Is.Not.Null, $"Service {serviceType} not registered"); + } + + [Test] + public void BuildCommands_CreateMatchmakerCommands() + { + var commandLineBuilder = new CommandLineBuilder(); + commandLineBuilder.AddModule(k_MatchmakerModule); + TestsHelper.AssertContainsCommand(commandLineBuilder.Command, k_MatchmakerModule.ModuleRootCommand!.Name, + out var resultCommand); + + Assert.That(resultCommand, Is.EqualTo(k_MatchmakerModule.ModuleRootCommand)); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Matchmaker.UnitTest/MatchmakerServiceUnitTests.cs b/Unity.Services.Cli/Unity.Services.Cli.Matchmaker.UnitTest/MatchmakerServiceUnitTests.cs new file mode 100644 index 0000000..5955f98 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Matchmaker.UnitTest/MatchmakerServiceUnitTests.cs @@ -0,0 +1,243 @@ +using System.Net; +using KellermanSoftware.CompareNetObjects; +using Moq; +using NUnit.Framework; +using Unity.Services.Cli.Common.Validator; +using Unity.Services.Cli.Matchmaker.Service; +using Unity.Services.Cli.Matchmaker.UnitTest.SampleConfigs; +using Unity.Services.Cli.ServiceAccountAuthentication; +using Unity.Services.Gateway.MatchmakerAdminApiV3.Generated.Api; +using Unity.Services.Gateway.MatchmakerAdminApiV3.Generated.Client; +using Generated = Unity.Services.Gateway.MatchmakerAdminApiV3.Generated.Model; + +namespace Unity.Services.Cli.Matchmaker.UnitTest; + +[TestFixture] +public class MatchmakerServiceTests +{ + Mock m_MockMatchmakerAdminApi = null!; + Mock m_MockAuthService = null!; + Mock m_MockConfigValidator = null!; + MatchmakerService m_Service = null!; + + CompareLogic m_CompareLogic = new CompareLogic(); + + [SetUp] + public void Setup() + { + m_CompareLogic.Config.ComparePrivateProperties = true; + m_CompareLogic.Config.ComparePrivateFields = true; + m_MockMatchmakerAdminApi = new Mock(); + m_MockAuthService = new Mock(); + m_MockConfigValidator = new Mock(); + m_Service = new MatchmakerService(m_MockMatchmakerAdminApi.Object, m_MockAuthService.Object, m_MockConfigValidator.Object); + } + + [Test] + public async Task Initialize_SetsProjectAndEnvironmentIds() + { + // Arrange + string projectId = "testProjectId"; + string environmentId = "testEnvironmentId"; + m_MockAuthService.Setup(x => x.GetAccessTokenAsync(It.IsAny())).ReturnsAsync("testToken"); + var mockHeaders = new Mock>(); // Mock the DefaultHeaders property + m_MockMatchmakerAdminApi.Setup(x => x.Configuration.DefaultHeaders).Returns(mockHeaders.Object); // Setup the mock + + string actualKey = ""; + string actualValue = ""; + mockHeaders.SetupSet(x => x[It.IsAny()] = It.IsAny()) + .Callback((k, v) => { actualKey = k; actualValue = v; }); + + // Act + await m_Service.Initialize(projectId, environmentId); + + // Assert + m_MockAuthService.Verify(x => x.GetAccessTokenAsync(It.IsAny()), Times.Once); + Assert.That(actualKey, Is.EqualTo("Authorization")); + Assert.That(actualValue, Is.EqualTo("Basic testToken")); + } + + [Test] + public async Task GetEnvironmentConfig_ReturnsEnvironmentConfig() + { + // Arrange + var expectedConfig = new Generated.EnvironmentConfig { Enabled = true, DefaultQueueName = "TestQueue" }; + m_MockMatchmakerAdminApi.Setup(x => x.GetEnvironmentConfigWithHttpInfoAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new ApiResponse(HttpStatusCode.OK, expectedConfig)); + + // Act + var (exists, config) = await m_Service.GetEnvironmentConfig(); + + // Assert + Assert.IsTrue(exists); + Assert.That(config, Is.EqualTo(expectedConfig)); + } + + [Test] + public async Task GetNonExistingEnvironmentConfig_ReturnsEmptyEnvironmentConfig() + { + // Arrange + m_MockMatchmakerAdminApi.Setup(x => x.GetEnvironmentConfigWithHttpInfoAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Throws(new ApiException(404, "Not found")); + + // Act + var (exists, _) = await m_Service.GetEnvironmentConfig(); + + // Assert + Assert.IsFalse(exists); + } + + [Test] + public async Task UpsertEnvironmentConfig_CallsUpdateEnvironmentConfigWithCorrectParameters() + { + // Arrange + var environmentConfig = new Generated.EnvironmentConfig { Enabled = true, DefaultQueueName = "TestQueue" }; + m_MockMatchmakerAdminApi.Setup(x => x.UpdateEnvironmentConfigWithHttpInfoAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new ApiResponse(HttpStatusCode.OK, new Object())); + + // Act + await m_Service.UpsertEnvironmentConfig(environmentConfig, false); + + // Assert + m_MockMatchmakerAdminApi.Verify(x => x.UpdateEnvironmentConfigWithHttpInfoAsync(It.IsAny(), It.IsAny(), false, environmentConfig, It.IsAny(), It.IsAny()), Times.Once); + } + + [Test] + public async Task UpsertEnvironmentConfig_CallsUpdateEnvironmentConfigWithInvalidParameters() + { + // Arrange + var environmentConfig = new Generated.EnvironmentConfig { Enabled = true, DefaultQueueName = "InvalidQueueName" }; + var problemDetails = new Generated.ProblemDetails() + { + Detail = "Mocked Error", + Details = new List() + { + new Generated.ProblemDetailsDetailsInner() + { + ResultCode = "MockedError", + Message = "Mocked Error Message" + } + } + }; + m_MockMatchmakerAdminApi.Setup( + x => x.UpdateEnvironmentConfigWithHttpInfoAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Throws(new ApiException(400, "Mocked error", problemDetails.ToJson())); + + // Act + var errors = await m_Service.UpsertEnvironmentConfig(environmentConfig, false); + + // Assert + m_MockMatchmakerAdminApi.Verify(x => x.UpdateEnvironmentConfigWithHttpInfoAsync(It.IsAny(), It.IsAny(), false, environmentConfig, It.IsAny(), It.IsAny()), Times.Once); + Assert.That(errors.Count, Is.EqualTo(1)); + Assert.That(errors.First().ResultCode, Is.EqualTo("MockedError")); + Assert.That(errors.First().Message, Is.EqualTo("Mocked Error Message")); + } + + [Test] + public async Task ListQueues_ReturnsListOfQueues() + { + // Arrange + var generatedSampleConfig = new GeneratedSampleConfig(); + var expectedQueues = new List { generatedSampleConfig.QueueConfig }; + m_MockMatchmakerAdminApi.Setup(x => x.ListQueuesWithHttpInfoAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new ApiResponse>(HttpStatusCode.OK, expectedQueues, $"[{generatedSampleConfig.QueueConfig}]")); + + // Act + var queues = await m_Service.ListQueues(); + + // Assert + var compResult = m_CompareLogic.Compare(generatedSampleConfig.QueueConfig, queues.First()); + Assert.IsTrue(compResult.AreEqual, compResult.DifferencesString); + Assert.That(queues, Is.EqualTo(expectedQueues)); + } + + [Test] + public async Task ListEmptyQueues_ReturnsEmptyListOfQueues() + { + // Arrange + m_MockMatchmakerAdminApi.Setup(x => x.ListQueuesWithHttpInfoAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Throws(new ApiException(404, "Not found")); + + // Act + var queues = await m_Service.ListQueues(); + + // Assert + Assert.That(queues, Is.Empty); + } + + [Test] + public async Task UpsertQueueConfig_CallsUpsertQueueConfigWithCorrectParameters() + { + // Arrange + var generatedSampleConfig = new GeneratedSampleConfig(); + var queueConfig = generatedSampleConfig.QueueConfig; + m_MockMatchmakerAdminApi.Setup(x => x.UpsertQueueConfigWithHttpInfoAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new ApiResponse(HttpStatusCode.OK, new Object())); + + // Act + await m_Service.UpsertQueueConfig(queueConfig, false); + + // Assert + m_MockMatchmakerAdminApi.Verify(x => x.UpsertQueueConfigWithHttpInfoAsync(It.IsAny(), It.IsAny(), queueConfig.Name, false, queueConfig, It.IsAny(), It.IsAny()), Times.Once); + } + + [Test] + public async Task UpsertQueue_CallsUpdateQueueWithInvalidParameters() + { + // Arrange + var queueConfig = new Generated.QueueConfig("MyQueue"); + var problemDetails = new Generated.ProblemDetails() + { + Detail = "Mocked Error", + Details = new List() + { + new Generated.ProblemDetailsDetailsInner() + { + ResultCode = "MockedError", + Message = "Mocked Error Message" + } + } + }; + m_MockMatchmakerAdminApi.Setup( + x => x.UpsertQueueConfigWithHttpInfoAsync( + It.IsAny(), + It.IsAny(), + "MyQueue", + false, + It.IsAny(), + It.IsAny(), + It.IsAny() + )) + .Throws(new ApiException(400, "Mocked error", problemDetails.ToJson())); + + // Act + var errors = await m_Service.UpsertQueueConfig(queueConfig, false); + + // Assert + m_MockMatchmakerAdminApi.Verify(x => x.UpsertQueueConfigWithHttpInfoAsync(It.IsAny(), It.IsAny(), "MyQueue", false, It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + Assert.That(errors.Count, Is.EqualTo(1)); + Assert.That(errors.First().ResultCode, Is.EqualTo("MockedError")); + Assert.That(errors.First().Message, Is.EqualTo("Mocked Error Message")); + } + + [Test] + public async Task DeleteQueue_CallsDeleteQueueWithCorrectParameters() + { + // Arrange + string queueName = "TestQueue"; + m_MockMatchmakerAdminApi.Setup(x => x.DeleteQueueAsync(It.IsAny(), It.IsAny(), queueName, It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + // Act + await m_Service.DeleteQueue(queueName, false); + + // Assert + m_MockMatchmakerAdminApi.Verify(x => x.DeleteQueueAsync(It.IsAny(), It.IsAny(), queueName, false, It.IsAny(), It.IsAny()), Times.Once); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Matchmaker.UnitTest/SampleConfigs/CoreSampleConfig.cs b/Unity.Services.Cli/Unity.Services.Cli.Matchmaker.UnitTest/SampleConfigs/CoreSampleConfig.cs new file mode 100644 index 0000000..efdc1b8 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Matchmaker.UnitTest/SampleConfigs/CoreSampleConfig.cs @@ -0,0 +1,320 @@ +using Core = Unity.Services.Matchmaker.Authoring.Core.Model; + +namespace Unity.Services.Cli.Matchmaker.UnitTest.SampleConfigs; + +class CoreSampleConfig +{ + internal Core.QueueConfig QueueConfig = new Core.QueueConfig() + { + Enabled = true, + MaxPlayersPerTicket = 1, + Name = new Core.QueueName("DefaultQueueTest"), + DefaultPool = new Core.BasePoolConfig() + { + Enabled = true, + Name = new Core.PoolName("TestPool"), + MatchHosting = new Core.MultiplayConfig() + { + FleetName = "TestFleet", + BuildConfigurationName = "TestBuildConfig", + DefaultQoSRegionName = "NorthAmerica", + }, + MatchLogic = new Core.MatchLogicRulesConfig() + { + Name = "TestMatchLogic", + BackfillEnabled = false, + MatchDefinition = new Core.RuleBasedMatchDefinition + { + teams = new List(), + matchRules = new List() + { + new Core.Rule() + { + type = Core.RuleType.Difference, + source = "Player.Skill", + name = "Skill", + reference = new AdminApiClient.MatchmakerAdminClient.JsonObjectSpecialized( + JsonSampleConfigLoader.WindowsLineEnding("[\n \"test_string\"\n]")), + enableRule = true, + not = true, + relaxations = new List() + { + new Core.RuleRelaxation() + { + type = Core.RuleRelaxationType.ReferenceControlReplace, + ageType = Core.AgeType.Oldest, + atSeconds = 30.0, + value = new AdminApiClient.MatchmakerAdminClient.JsonObjectSpecialized("100"), + } + } + }, + new Core.Rule() + { + name = "CloudSaveElo", + source = "ExternalData.CloudSave.myObject", + type = Core.RuleType.GreaterThan, + not = false, + reference = new AdminApiClient.MatchmakerAdminClient.JsonObjectSpecialized("\"value\""), + externalData = new Core.RuleExternalData() + { + cloudSave = new Core.RuleExternalData.CloudSave() + { + accessClass = Core.RuleExternalData.CloudSave.AccessClass.Private, + _default = new AdminApiClient.MatchmakerAdminClient.JsonObjectSpecialized( + JsonSampleConfigLoader.WindowsLineEnding("{\n \"myObject\": \"defaultValue\"\n}")) + } + }, + relaxations = new List() + }, + new Core.Rule() + { + name = "LeaderboardTiers", + source = "ExternalData.Leaderboard.Tiers", + type = Core.RuleType.GreaterThanEqual, + not = false, + reference = new AdminApiClient.MatchmakerAdminClient.JsonObjectSpecialized("156"), + externalData = new Core.RuleExternalData() + { + leaderboard = new Core.RuleExternalData.Leaderboard() + { + id = "MyLeaderboardId" + } + }, + relaxations = new List() + }, + new Core.Rule() + { + name = "LessThan", + source = "attribute", + type = Core.RuleType.LessThan, + reference = new AdminApiClient.MatchmakerAdminClient.JsonObjectSpecialized("2"), + relaxations = new List() + { + new Core.RuleRelaxation() + { + ageType = Core.AgeType.Youngest, + atSeconds = 2, + type = Core.RuleRelaxationType.RuleControlDisable, + }, + new Core.RuleRelaxation() + { + ageType = Core.AgeType.Average, + atSeconds = 4, + type = Core.RuleRelaxationType.RuleControlEnable, + value = new AdminApiClient.MatchmakerAdminClient.JsonObjectSpecialized("2"), + } + }, + externalData = new Core.RuleExternalData() + { + cloudSave = new Core.RuleExternalData.CloudSave() + { + accessClass = Core.RuleExternalData.CloudSave.AccessClass.Public, + _default = new AdminApiClient.MatchmakerAdminClient.JsonObjectSpecialized("3") + } + }, + }, + new Core.Rule() + { + name = "LessThanEqual", + source = "attribute", + type = Core.RuleType.LessThanEqual, + reference = new AdminApiClient.MatchmakerAdminClient.JsonObjectSpecialized("2"), + externalData = new Core.RuleExternalData() + { + cloudSave = new Core.RuleExternalData.CloudSave() + { + accessClass = Core.RuleExternalData.CloudSave.AccessClass.Protected, + _default = new AdminApiClient.MatchmakerAdminClient.JsonObjectSpecialized("3") + } + }, + }, + new Core.Rule() + { + name = "Equality", + source = "attribute", + type = Core.RuleType.Equality, + reference = new AdminApiClient.MatchmakerAdminClient.JsonObjectSpecialized("2") + }, + new Core.Rule() + { + name = "InList", + source = "attribute", + type = Core.RuleType.InList, + reference = new AdminApiClient.MatchmakerAdminClient.JsonObjectSpecialized(JsonSampleConfigLoader.WindowsLineEnding("[\n 2,\n 3\n]")) + }, + new Core.Rule() + { + name = "Intersection", + source = "attribute", + type = Core.RuleType.Intersection, + reference = new AdminApiClient.MatchmakerAdminClient.JsonObjectSpecialized(JsonSampleConfigLoader.WindowsLineEnding("[\n 2,\n 3\n]")) + }, + } + } + }, + Variants = new List() + { + new Core.PoolConfig() + { + Name = new Core.PoolName("VariantOfDefault"), + Enabled = true, + TimeoutSeconds = 15, + MatchHosting = new Core.MatchIdConfig(), + MatchLogic = new Core.MatchLogicRulesConfig() + { + Name = "VariantMatchLogic", + BackfillEnabled = true, + MatchDefinition = new Core.RuleBasedMatchDefinition() + { + teams = new List() + { + new Core.RuleBasedTeamDefinition() + { + name = "rule", + teamCount = new Core.Range() + { + min = 2, + max = 2, + }, + playerCount = new Core.Range() + { + min = 1, + max = 10, + } + } + } + } + } + } + } + }, + FilteredPools = new List() + { + new Core.FilteredPoolConfig() + { + Filters = new List() + { + new Core.FilteredPoolConfig.Filter() + { + Attribute = "Game-mode-eq", + Value = new AdminApiClient.MatchmakerAdminClient.JsonObjectSpecialized("\"TDM\""), + Operator = Core.FilteredPoolConfig.Filter.FilterOperator.Equal, + }, + new Core.FilteredPoolConfig.Filter() + { + Attribute = "Game-mode-number-lt", + Value = new AdminApiClient.MatchmakerAdminClient.JsonObjectSpecialized("10.5"), + Operator = Core.FilteredPoolConfig.Filter.FilterOperator.LessThan, + }, + new Core.FilteredPoolConfig.Filter() + { + Attribute = "Game-mode-number-gt", + Value = new AdminApiClient.MatchmakerAdminClient.JsonObjectSpecialized("10.5"), + Operator = Core.FilteredPoolConfig.Filter.FilterOperator.GreaterThan, + }, + new Core.FilteredPoolConfig.Filter() + { + Attribute = "Game-mode-number-ne", + Value = new AdminApiClient.MatchmakerAdminClient.JsonObjectSpecialized("10.5"), + Operator = Core.FilteredPoolConfig.Filter.FilterOperator.NotEqual, + } + }, + Name = new Core.PoolName("FilteredPool"), + Enabled = false, + MatchHosting = new Core.MatchIdConfig(), + MatchLogic = new Core.MatchLogicRulesConfig() + { + Name = "TestFilteredMatchLogic", + MatchDefinition = new Core.RuleBasedMatchDefinition() + { + teams = new List() + { + new Core.RuleBasedTeamDefinition() + { + name = "matchSize", + teamCount = new Core.Range() + { + min = 2, + max = 2, + relaxations = new List() + { + new Core.RangeRelaxation() + { + ageType = Core.AgeType.Youngest, + type = Core.RangeRelaxationType.RangeControlReplaceMin, + value = 1, + atSeconds = 23, + } + } + }, + playerCount = new Core.Range() + { + min = 7, + max = 10, + relaxations = new List() + { + new Core.RangeRelaxation() + { + ageType = Core.AgeType.Oldest, + type = Core.RangeRelaxationType.RangeControlReplaceMin, + value = 2.0, + atSeconds = 30.0, + } + } + }, + teamRules = new List() + { + new Core.Rule() + { + type = Core.RuleType.LessThan, + source = "QoS.Latency", + name = "Latency", + reference = new AdminApiClient.MatchmakerAdminClient.JsonObjectSpecialized("250.7"), + enableRule = false, + not = false + } + } + } + } + }, + BackfillEnabled = true + }, + Variants = new List() + { + new Core.PoolConfig() + { + Name = new Core.PoolName("VariantPool"), + Enabled = false, + TimeoutSeconds = 5, + MatchHosting = new Core.MatchIdConfig(), + MatchLogic = new Core.MatchLogicRulesConfig() + { + Name = "logic", + BackfillEnabled = false, + MatchDefinition = new Core.RuleBasedMatchDefinition() + { + matchRules = new List(), + teams = new List() + } + } + } + } + }, + } + }; + + internal Core.QueueConfig EmptyQueueConfig = new Core.QueueConfig() + { + Name = new Core.QueueName("EmptyQueue"), + Enabled = true, + MaxPlayersPerTicket = 2, + FilteredPools = new List() + }; + + internal readonly Core.EnvironmentConfig EnvironmentConfig = new Core.EnvironmentConfig() + { + Type = Core.IMatchmakerConfig.ConfigType.EnvironmentConfig, + Enabled = true, + DefaultQueueName = new Core.QueueName("DefaultQueueTest"), + }; +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Matchmaker.UnitTest/SampleConfigs/GeneratedSampleConfig.cs b/Unity.Services.Cli/Unity.Services.Cli.Matchmaker.UnitTest/SampleConfigs/GeneratedSampleConfig.cs new file mode 100644 index 0000000..9806846 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Matchmaker.UnitTest/SampleConfigs/GeneratedSampleConfig.cs @@ -0,0 +1,297 @@ +namespace Unity.Services.Cli.Matchmaker.UnitTest.SampleConfigs; +using Generated = Gateway.MatchmakerAdminApiV3.Generated.Model; + +class GeneratedSampleConfig +{ + struct TestTruct + { + public string myObject; + + public TestTruct() + { + myObject = "defaultValue"; + } + + public TestTruct(string myObject) + { + this.myObject = myObject; + } + } + + public Generated.QueueConfig QueueConfig + { + get => new Generated.QueueConfig( + name: "DefaultQueueTest", + enabled: true, + maxPlayersPerTicket: 1, + defaultPool: new Generated.BasePoolConfig( + name: "TestPool", + enabled: true, + matchHosting: new Generated.MatchHosting( + new Generated.MultiplayHostingConfig( + type: Generated.MultiplayHostingConfig.TypeEnum.Multiplay, + fleetId: "e8b109e1-6746-4ce6-9c21-3330509554a1", + buildConfigurationId: "74874928923749", + defaultQoSRegionId: "3eac13c4-bf61-4b05-83df-eed5732ad305" + )), + matchLogic: new Generated.Rules( + name: "TestMatchLogic", + backfillEnabled: false, + matchDefinition: new Generated.RuleBasedMatchDefinition( + matchRules: new List() + { + new Generated.Rule( + name: "Skill", + source: "Player.Skill", + type: Generated.Rule.TypeEnum.Difference, + not: true, + reference: new[] { "test_string" }, + enableRule: true, + relaxations: new List() + { + new Generated.RuleRelaxation( + type: Generated.RuleRelaxation.TypeEnum.ReferenceControlReplace, + value: 100, + atSeconds: 30, + ageType: Generated.AgeType.Oldest + ), + } + ), + new Generated.Rule( + name: "CloudSaveElo", + source: "ExternalData.CloudSave.myObject", + type: Generated.Rule.TypeEnum.GreaterThan, + not: false, + reference: "value", + externalData: new Generated.RuleExternalData + ( + cloudSave: new Generated.RuleExternalDataCloudSave( + accessClass: Generated.RuleExternalDataCloudSave.AccessClassEnum.Private, + _default: new TestTruct() + ) + ), + relaxations: new List() + ), + new Generated.Rule( + name: "LeaderboardTiers", + source: "ExternalData.Leaderboard.Tiers", + type: Generated.Rule.TypeEnum.GreaterThanEqual, + not: false, + reference: 156, + externalData: new Generated.RuleExternalData + ( + leaderboard: new Generated.RuleExternalDataLeaderboard + ( + id: "MyLeaderboardId" + ) + ), + relaxations: new List() + ), + new Generated.Rule( + name: "LessThan", + source: "attribute", + type: Generated.Rule.TypeEnum.LessThan, + reference: 2, + relaxations: new List() + { + new Generated.RuleRelaxation( + ageType: Generated.AgeType.Youngest, + atSeconds: 2, + type: Generated.RuleRelaxation.TypeEnum.RuleControlDisable + ), + new Generated.RuleRelaxation( + ageType: Generated.AgeType.Average, + atSeconds: 4, + type: Generated.RuleRelaxation.TypeEnum.RuleControlEnable, + value: 2 + ), + }, + externalData: new Generated.RuleExternalData( + cloudSave: new Generated.RuleExternalDataCloudSave( + accessClass: Generated.RuleExternalDataCloudSave.AccessClassEnum.Public, + _default: 3 + ) + ) + ), + new Generated.Rule( + name: "LessThanEqual", + source: "attribute", + type: Generated.Rule.TypeEnum.LessThanEqual, + reference: 2, + externalData: new Generated.RuleExternalData( + cloudSave: new Generated.RuleExternalDataCloudSave( + accessClass: Generated.RuleExternalDataCloudSave.AccessClassEnum.Protected, + _default: 3 + ) + ), + relaxations: new List() + ), + new Generated.Rule( + name: "Equality", + source: "attribute", + type: Generated.Rule.TypeEnum.Equality, + reference: 2, + relaxations: new List() + ), + new Generated.Rule( + name: "InList", + source: "attribute", + type: Generated.Rule.TypeEnum.InList, + reference: new List {2, 3}, + relaxations: new List() + ), + new Generated.Rule( + name: "Intersection", + source: "attribute", + type: Generated.Rule.TypeEnum.Intersection, + reference: new List {2, 3}, + relaxations: new List() + ), + }, + teams: new List() + )), + variants: new List() + { + new Generated.PoolConfig( + name: "VariantOfDefault", + enabled: true, + matchHosting: new Generated.MatchHosting(new Generated.MatchIdHostingConfig(Generated.MatchIdHostingConfig.TypeEnum.MatchId)), + timeoutSeconds: 15, + new Generated.Rules( + name: "VariantMatchLogic", + backfillEnabled: true, + matchDefinition: new Generated.RuleBasedMatchDefinition( + matchRules: new List(), + teams: new List() + { + new ( + name:"rule", + teamCount: new Generated.Range(min:2, max:2, relaxations: new List()), + playerCount: new Generated.Range(1, 10, relaxations: new List()), + teamRules: new List() + ) + } + ) + ) + ) + } + ), + filteredPools: new List() + { + new Generated.FilteredPoolConfig( + name: "FilteredPool", + enabled: false, + filters: new List() + { + new Generated.Filter( + attribute: "Game-mode-eq", + value: "TDM", + _operator: Generated.Filter.OperatorEnum.Equal + ), + new Generated.Filter( + attribute: "Game-mode-number-lt", + value: 10.5, + _operator: Generated.Filter.OperatorEnum.LessThan + ), + new Generated.Filter( + attribute: "Game-mode-number-gt", + value: 10.5, + _operator: Generated.Filter.OperatorEnum.GreaterThan + ), + new Generated.Filter( + attribute: "Game-mode-number-ne", + value: 10.5, + _operator: Generated.Filter.OperatorEnum.NotEqual + ) + }, + matchHosting: new Generated.MatchHosting( + new Generated.MatchIdHostingConfig() + { + Type = Generated.MatchIdHostingConfig.TypeEnum.MatchId + }), + matchLogic: new Generated.Rules( + name: "TestFilteredMatchLogic", + backfillEnabled: true, + matchDefinition: new Generated.RuleBasedMatchDefinition( + teams: new List() + { + new Generated.RuleBasedTeamDefinition( + name: "matchSize", + playerCount: new Generated.Range( + max: 10, + min: 7, + relaxations: new List() + { + new Generated.RangeRelaxation( + ageType: Generated.AgeType.Oldest, + atSeconds: 30, + type: Generated.RangeRelaxation.TypeEnum.RangeControlReplaceMin, + value: 2 + ) + } + ), + teamCount: new Generated.Range( + min: 2, + max: 2, + relaxations: new List() + { + new Generated.RangeRelaxation( + ageType: Generated.AgeType.Youngest, + atSeconds: 23, + type: Generated.RangeRelaxation.TypeEnum.RangeControlReplaceMin, + value: 1) + } + ), + teamRules: new List() + { + new Generated.Rule( + name: "Latency", + source: "QoS.Latency", + type: Generated.Rule.TypeEnum.LessThan, + not: false, + reference: 250.7, + enableRule: false, + relaxations: new List() + ), + } + ) + }, + matchRules: new List() + ) + ), + variants: new List() + { + new Generated.PoolConfig( + name: "VariantPool", + enabled: false, + timeoutSeconds: 5, + matchLogic: new Generated.Rules( + name: "logic", + backfillEnabled: false, + matchDefinition: new Generated.RuleBasedMatchDefinition( + matchRules: new List(), + teams: new List() + ) + ), + matchHosting: new Generated.MatchHosting( + new Generated.MatchIdHostingConfig() + { + Type = Generated.MatchIdHostingConfig.TypeEnum.MatchId + } + ) + ) + } + ), + } + ); + } + + public Generated.QueueConfig EmptyQueueConfig + { + get => new Generated.QueueConfig( + name: "EmptyQueue", + enabled: true, + maxPlayersPerTicket: 2, + filteredPools: new List()); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Matchmaker.UnitTest/SampleConfigs/JsonSampleConfigLoader.cs b/Unity.Services.Cli/Unity.Services.Cli.Matchmaker.UnitTest/SampleConfigs/JsonSampleConfigLoader.cs new file mode 100644 index 0000000..63a85c8 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Matchmaker.UnitTest/SampleConfigs/JsonSampleConfigLoader.cs @@ -0,0 +1,39 @@ +using System.Reflection; +using System.Runtime.InteropServices; + +namespace Unity.Services.Cli.Matchmaker.UnitTest.SampleConfigs; + +static class JsonSampleConfigLoader +{ + internal static readonly string QueueConfig = GetJsonFromResources("TestQueueConfig.json"); + internal static readonly string EmptyQueueConfig = GetJsonFromResources("TestEmptyQueueConfig.json"); + internal static readonly string TemplateQueueConfig = GetJsonFromResources("TemplateQueueConfig.json"); + internal static readonly string EnvironmentConfig = GetJsonFromResources("TestEnvironmentConfig.json"); + + // Windows would serialize those with \n which is fine so fix that in SampleConfig + internal static string WindowsLineEnding(string originalJson) + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + return originalJson; + + string replacedJson = originalJson.Replace("\n", "\r\n"); + if (replacedJson.EndsWith('\n')) + replacedJson = replacedJson.Remove(replacedJson.Length - 2) + "\n"; + + return replacedJson; + } + + static string GetJsonFromResources(string resourceName) + { + var assembly = Assembly.GetExecutingAssembly(); + + using Stream? stream = assembly.GetManifestResourceStream($"Unity.Services.Cli.Matchmaker.UnitTest.SampleConfigs.{resourceName}"); + if (stream != null) + { + using StreamReader reader = new StreamReader(stream); + return WindowsLineEnding(reader.ReadToEnd()); + } + + return string.Empty; + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Matchmaker.UnitTest/SampleConfigs/MultiplaySampleConfig.cs b/Unity.Services.Cli/Unity.Services.Cli.Matchmaker.UnitTest/SampleConfigs/MultiplaySampleConfig.cs new file mode 100644 index 0000000..08cb838 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Matchmaker.UnitTest/SampleConfigs/MultiplaySampleConfig.cs @@ -0,0 +1,82 @@ +using Unity.Services.Gateway.GameServerHostingApiV1.Generated.Model; +using Unity.Services.Multiplay.Authoring.Core.Assets; +using Core = Unity.Services.Matchmaker.Authoring.Core.Model; + +namespace Unity.Services.Cli.Matchmaker.UnitTest.SampleConfigs; + +class MultiplaySampleConfig +{ + public Core.MultiplayResources LocalResources = new Core.MultiplayResources() + { + Fleets = new List() + { + new Core.MultiplayResources.Fleet() + { + Name = "TestFleet", + Id = "e8b109e1-6746-4ce6-9c21-3330509554a1", + BuildConfigs = new List() + { + new Core.MultiplayResources.Fleet.BuildConfig() + { + Name = "TestBuildConfig", + Id = "74874928923749" + } + }, + QosRegions = new List() + { + new Core.MultiplayResources.Fleet.QosRegion() + { + Name = "NorthAmerica", + Id = "3eac13c4-bf61-4b05-83df-eed5732ad305" + } + } + } + } + }; + + public MultiplayConfig LocalConfigs = new MultiplayConfig() + { + Fleets = new Dictionary() + { + { + new FleetName() { Name = "TestFleet" }, new MultiplayConfig.FleetDefinition() + { + BuildConfigurations = new List() + { new BuildConfigurationName() { Name = "TestBuildConfig" } }, + Regions = new Dictionary() + { + { "NorthAmerica", new MultiplayConfig.ScalingDefinition() } + } + } + } + } + }; + + public List RemoteFleets = new List() + { + new FleetListItem( + name: "TestFleet", + id: Guid.Parse("e8b109e1-6746-4ce6-9c21-3330509554a1"), + osName: "Windows", + osID: Guid.Parse("f84109e1-1746-4ce6-9f21-3330509554a1"), + servers: new Servers( + all: new FleetServerBreakdown(status: new ServerStatus()), + cloud: new FleetServerBreakdown(status: new ServerStatus()), + metal: new FleetServerBreakdown(status: new ServerStatus())), + buildConfigurations: new List() + { + new BuildConfiguration1( + name: "TestBuildConfig", + id: 74874928923749 + ) + }, + regions: new List() + { + new FleetRegion( + regionName: "NorthAmerica", + regionID: Guid.Parse("3eac13c4-bf61-4b05-83df-eed5732ad305") + ) + } + ) + }; +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Matchmaker.UnitTest/SampleConfigs/TemplateQueueConfig.json b/Unity.Services.Cli/Unity.Services.Cli.Matchmaker.UnitTest/SampleConfigs/TemplateQueueConfig.json new file mode 100644 index 0000000..cd2a190 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Matchmaker.UnitTest/SampleConfigs/TemplateQueueConfig.json @@ -0,0 +1,70 @@ +{ + "$schema": "https://ugs-config-schemas.unity3d.com/v1/matchmaker/matchmaker-queue.schema.json", + "name": "default-queue", + "enabled": true, + "maxPlayersPerTicket": 2, + "defaultPool": { + "variants": [], + "name": "default-pool", + "enabled": true, + "timeoutSeconds": 90, + "matchLogic": { + "matchDefinition": { + "teams": [ + { + "name": "Team", + "teamCount": { + "min": 2, + "max": 2, + "relaxations": [] + }, + "playerCount": { + "min": 1, + "max": 2, + "relaxations": [] + }, + "teamRules": [] + } + ], + "matchRules": [ + { + "source": "Players.ExternalData.CloudSave.Skill", + "name": "skill-diff", + "type": "Difference", + "reference": 500, + "overlap": 0.0, + "enableRule": false, + "not": false, + "relaxations": [] + }, + { + "source": "Players.QosResults.Latency", + "name": "QoS", + "type": "LessThanEqual", + "reference": 100, + "overlap": 0.0, + "enableRule": false, + "not": false, + "relaxations": [ + { + "type": "ReferenceControl.Replace", + "ageType": "Oldest", + "atSeconds": 30.0, + "value": 200 + } + ] + } + ] + }, + "name": "Default Pool Rules", + "backfillEnabled": false + }, + "matchHosting": { + "type": "Multiplay", + "fleetName": "my fleet", + "buildConfigurationName": "my build configuration", + "defaultQoSRegionName": "North America" + } + }, + "filteredPools": [] +} \ No newline at end of file diff --git a/Unity.Services.Cli/Unity.Services.Cli.Matchmaker.UnitTest/SampleConfigs/TestEmptyQueueConfig.json b/Unity.Services.Cli/Unity.Services.Cli.Matchmaker.UnitTest/SampleConfigs/TestEmptyQueueConfig.json new file mode 100644 index 0000000..2129c12 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Matchmaker.UnitTest/SampleConfigs/TestEmptyQueueConfig.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://ugs-config-schemas.unity3d.com/v1/matchmaker/matchmaker-queue.schema.json", + "name": "EmptyQueue", + "enabled": true, + "maxPlayersPerTicket": 2, + "filteredPools": [] +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Matchmaker.UnitTest/SampleConfigs/TestEnvironmentConfig.json b/Unity.Services.Cli/Unity.Services.Cli.Matchmaker.UnitTest/SampleConfigs/TestEnvironmentConfig.json new file mode 100644 index 0000000..d6e9ba4 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Matchmaker.UnitTest/SampleConfigs/TestEnvironmentConfig.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://ugs-config-schemas.unity3d.com/v1/matchmaker/matchmaker-environment-config.schema.json", + "enabled": true, + "defaultQueueName": "DefaultQueueTest" +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Matchmaker.UnitTest/SampleConfigs/TestQueueConfig.json b/Unity.Services.Cli/Unity.Services.Cli.Matchmaker.UnitTest/SampleConfigs/TestQueueConfig.json new file mode 100644 index 0000000..2918654 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Matchmaker.UnitTest/SampleConfigs/TestQueueConfig.json @@ -0,0 +1,289 @@ +{ + "$schema": "https://ugs-config-schemas.unity3d.com/v1/matchmaker/matchmaker-queue.schema.json", + "name": "DefaultQueueTest", + "enabled": true, + "maxPlayersPerTicket": 1, + "defaultPool": { + "variants": [ + { + "name": "VariantOfDefault", + "enabled": true, + "timeoutSeconds": 15, + "matchLogic": { + "matchDefinition": { + "teams": [ + { + "name": "rule", + "teamCount": { + "min": 2, + "max": 2, + "relaxations": [] + }, + "playerCount": { + "min": 1, + "max": 10, + "relaxations": [] + }, + "teamRules": [] + } + ], + "matchRules": [] + }, + "name": "VariantMatchLogic", + "backfillEnabled": true + }, + "matchHosting": { + "type": "MatchId" + } + } + ], + "name": "TestPool", + "enabled": true, + "timeoutSeconds": 0, + "matchLogic": { + "matchDefinition": { + "teams": [], + "matchRules": [ + { + "source": "Player.Skill", + "name": "Skill", + "type": "Difference", + "reference": [ + "test_string" + ], + "overlap": 0.0, + "enableRule": true, + "not": true, + "relaxations": [ + { + "type": "ReferenceControl.Replace", + "ageType": "Oldest", + "atSeconds": 30.0, + "value": 100 + } + ] + }, + { + "source": "ExternalData.CloudSave.myObject", + "name": "CloudSaveElo", + "type": "GreaterThan", + "reference": "value", + "overlap": 0.0, + "enableRule": false, + "not": false, + "relaxations": [], + "externalData": { + "cloudSave": { + "accessClass": "Private", + "default": { + "myObject": "defaultValue" + } + } + } + }, + { + "source": "ExternalData.Leaderboard.Tiers", + "name": "LeaderboardTiers", + "type": "GreaterThanEqual", + "reference": 156, + "overlap": 0.0, + "enableRule": false, + "not": false, + "relaxations": [], + "externalData": { + "leaderboard": { + "id": "MyLeaderboardId" + } + } + }, + { + "source": "attribute", + "name": "LessThan", + "type": "LessThan", + "reference": 2, + "overlap": 0.0, + "enableRule": false, + "not": false, + "relaxations": [ + { + "type": "RuleControl.Disable", + "ageType": "Youngest", + "atSeconds": 2.0 + }, + { + "type": "RuleControl.Enable", + "ageType": "Average", + "atSeconds": 4.0, + "value": 2 + } + ], + "externalData": { + "cloudSave": { + "accessClass": "Public", + "default": 3 + } + } + }, + { + "source": "attribute", + "name": "LessThanEqual", + "type": "LessThanEqual", + "reference": 2, + "overlap": 0.0, + "enableRule": false, + "not": false, + "relaxations": [], + "externalData": { + "cloudSave": { + "accessClass": "Protected", + "default": 3 + } + } + }, + { + "source": "attribute", + "name": "Equality", + "type": "Equality", + "reference": 2, + "overlap": 0.0, + "enableRule": false, + "not": false, + "relaxations": [] + }, + { + "source": "attribute", + "name": "InList", + "type": "InList", + "reference": [ + 2, + 3 + ], + "overlap": 0.0, + "enableRule": false, + "not": false, + "relaxations": [] + }, + { + "source": "attribute", + "name": "Intersection", + "type": "Intersection", + "reference": [ + 2, + 3 + ], + "overlap": 0.0, + "enableRule": false, + "not": false, + "relaxations": [] + } + ] + }, + "name": "TestMatchLogic", + "backfillEnabled": false + }, + "matchHosting": { + "type": "Multiplay", + "fleetName": "TestFleet", + "buildConfigurationName": "TestBuildConfig", + "defaultQoSRegionName": "NorthAmerica" + } + }, + "filteredPools": [ + { + "filters": [ + { + "attribute": "Game-mode-eq", + "operator": "Equal", + "value": "TDM" + }, + { + "attribute": "Game-mode-number-lt", + "operator": "LessThan", + "value": 10.5 + }, + { + "attribute": "Game-mode-number-gt", + "operator": "GreaterThan", + "value": 10.5 + }, + { + "attribute": "Game-mode-number-ne", + "operator": "NotEqual", + "value": 10.5 + } + ], + "variants": [ + { + "name": "VariantPool", + "enabled": false, + "timeoutSeconds": 5, + "matchLogic": { + "matchDefinition": { + "teams": [], + "matchRules": [] + }, + "name": "logic", + "backfillEnabled": false + }, + "matchHosting": { + "type": "MatchId" + } + } + ], + "name": "FilteredPool", + "enabled": false, + "timeoutSeconds": 0, + "matchLogic": { + "matchDefinition": { + "teams": [ + { + "name": "matchSize", + "teamCount": { + "min": 2, + "max": 2, + "relaxations": [ + { + "type": "RangeControl.ReplaceMin", + "ageType": "Youngest", + "atSeconds": 23.0, + "value": 1.0 + } + ] + }, + "playerCount": { + "min": 7, + "max": 10, + "relaxations": [ + { + "type": "RangeControl.ReplaceMin", + "ageType": "Oldest", + "atSeconds": 30.0, + "value": 2.0 + } + ] + }, + "teamRules": [ + { + "source": "QoS.Latency", + "name": "Latency", + "type": "LessThan", + "reference": 250.7, + "overlap": 0.0, + "enableRule": false, + "not": false, + "relaxations": [] + } + ] + } + ], + "matchRules": [] + }, + "name": "TestFilteredMatchLogic", + "backfillEnabled": true + }, + "matchHosting": { + "type": "MatchId" + } + } + ] +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Matchmaker.UnitTest/Unity.Services.Cli.Matchmaker.UnitTest.csproj b/Unity.Services.Cli/Unity.Services.Cli.Matchmaker.UnitTest/Unity.Services.Cli.Matchmaker.UnitTest.csproj new file mode 100644 index 0000000..229af7a --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Matchmaker.UnitTest/Unity.Services.Cli.Matchmaker.UnitTest.csproj @@ -0,0 +1,32 @@ + + + net8.0 + enable + 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.Matchmaker/AdminApiClient/MatchmakerAdminClient.cs b/Unity.Services.Cli/Unity.Services.Cli.Matchmaker/AdminApiClient/MatchmakerAdminClient.cs new file mode 100644 index 0000000..126d589 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Matchmaker/AdminApiClient/MatchmakerAdminClient.cs @@ -0,0 +1,121 @@ +using Newtonsoft.Json; +using Unity.Services.Cli.GameServerHosting.Service; +using Unity.Services.Cli.Matchmaker.Parser; +using Unity.Services.Cli.Matchmaker.Service; +using Unity.Services.Gateway.GameServerHostingApiV1.Generated.Model; +using Unity.Services.Matchmaker.Authoring.Core.ConfigApi; +using Unity.Services.Matchmaker.Authoring.Core.Model; +using Generated = Unity.Services.Gateway.MatchmakerAdminApiV3.Generated.Model; + +namespace Unity.Services.Cli.Matchmaker.AdminApiClient; + +class MatchmakerAdminClient : IConfigApiClient +{ + readonly IMatchmakerService m_Service; + readonly IGameServerHostingService m_GameServerHostingService; + MultiplayResources m_RemoteMultiplayResources; + + public MatchmakerAdminClient(IMatchmakerService service, IGameServerHostingService gameServerHostingService) + { + m_Service = service; + m_GameServerHostingService = gameServerHostingService; + } + + public async Task Initialize(string projectId, string environmentId, CancellationToken ct = default) + { + var settings = JsonConvert.DefaultSettings?.Invoke() ?? new JsonSerializerSettings(); + if (settings.Converters.All(c => c.GetType() != typeof(JsonObjectSpecializedConverter))) + settings.Converters.Add(new JsonObjectSpecializedConverter()); + JsonConvert.DefaultSettings = () => settings; + await m_GameServerHostingService.AuthorizeGameServerHostingService(ct); + var fleets = m_GameServerHostingService.FleetsApi.ListFleets(Guid.Parse(projectId), Guid.Parse(environmentId)); + m_RemoteMultiplayResources = MpResourcesFromFleetAndBuildConfig(fleets); + return await m_Service.Initialize(projectId, environmentId, ct); + } + + static MultiplayResources MpResourcesFromFleetAndBuildConfig(List fleets) + { + return new MultiplayResources() + { + Fleets = fleets.Select( + f => new MultiplayResources.Fleet() + { + Name = f.Name, + Id = f.Id.ToString(), + BuildConfigs = f.BuildConfigurations + .Select( + bc => new MultiplayResources.Fleet.BuildConfig() + { + Name = bc.Name, + Id = bc.Id.ToString() + }) + .ToList(), + QosRegions = f.Regions.Select( + qr => new MultiplayResources.Fleet.QosRegion() + { + Name = qr.RegionName, + Id = qr.RegionID.ToString() + }) + .ToList() + }) + .ToList() + }; + } + + public async Task<(bool, EnvironmentConfig)> GetEnvironmentConfig(CancellationToken ct = default) + { + + var (exist, genEnvConfig) = await m_Service.GetEnvironmentConfig(ct); + if (!exist) + return (false, new EnvironmentConfig()); + return (true, new EnvironmentConfig + { + DefaultQueueName = new QueueName(genEnvConfig?.DefaultQueueName ?? ""), + Enabled = genEnvConfig?.Enabled ?? false + }); + } + + public async Task> UpsertEnvironmentConfig(EnvironmentConfig environmentConfig, bool dryRun, CancellationToken ct = default) + { + var genEnvConfig = new Generated.EnvironmentConfig + ( + defaultQueueName: environmentConfig.DefaultQueueName.ToString() ?? string.Empty, + enabled: environmentConfig.Enabled + ); + return await m_Service.UpsertEnvironmentConfig(genEnvConfig, dryRun, ct); + } + + public async Task)>> ListQueues(CancellationToken ct = default) + { + var genQueues = await m_Service.ListQueues(ct); + return genQueues?.Select(f => ModelGeneratedToCore.FromGeneratedQueueConfig(f, m_RemoteMultiplayResources)).ToList() ?? new List<(QueueConfig, List)>(); + } + + public async Task> UpsertQueue(QueueConfig queueConfig, MultiplayResources availableMultiplayResources, bool dryRun, CancellationToken ct = default) + { + var (genQueueConfig, errors) = ModelCoreToGenerated.FromCoreQueueConfig(queueConfig, availableMultiplayResources, dryRun); + if (errors.Count > 0) + return errors; + return await m_Service.UpsertQueueConfig( + genQueueConfig, + dryRun, + ct); + } + + public async Task DeleteQueue(QueueName queueName, bool dryRun, CancellationToken ct = default) + { + await m_Service.DeleteQueue(queueName.ToString() ?? string.Empty, dryRun, ct); + } + + MultiplayResources IConfigApiClient.GetRemoteMultiplayResources() => m_RemoteMultiplayResources; + + // This class allows us to add the JsonObjectSpecializedConverter to the Core JsonObject class + [JsonConverter(typeof(JsonObjectSpecializedConverter))] + public class JsonObjectSpecialized : JsonObject + { + public JsonObjectSpecialized(string value) : base(value) + { + } + } + +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Matchmaker/AdminApiClient/ModelCoreToGenerated.cs b/Unity.Services.Cli/Unity.Services.Cli.Matchmaker/AdminApiClient/ModelCoreToGenerated.cs new file mode 100644 index 0000000..fb6ceec --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Matchmaker/AdminApiClient/ModelCoreToGenerated.cs @@ -0,0 +1,317 @@ +using System.ComponentModel; +using System.Globalization; +using Core = Unity.Services.Matchmaker.Authoring.Core.Model; +using Generated = Unity.Services.Gateway.MatchmakerAdminApiV3.Generated.Model; + +namespace Unity.Services.Cli.Matchmaker.AdminApiClient; + +static class ModelCoreToGenerated +{ + + internal static (Generated.QueueConfig, List) FromCoreQueueConfig( + Core.QueueConfig queueConfig, + Core.MultiplayResources availableMultiplayResources, + bool dryRun) + { + var (defaultPool, errorResponses) = FromCoreBasePoolConfig( + queueConfig.DefaultPool, + availableMultiplayResources, + dryRun); + + var filteredPools = queueConfig.FilteredPools?.Select(f => FromCoreFilteredPoolConfig(f, availableMultiplayResources, dryRun)).ToList(); + errorResponses.AddRange(filteredPools?.Select(f => f.Item2).SelectMany(f => f) ?? new List()); + + return ( + new Generated.QueueConfig( + name: queueConfig.Name.ToString() ?? string.Empty, + enabled: queueConfig.Enabled, + maxPlayersPerTicket: queueConfig.MaxPlayersPerTicket, + defaultPool: defaultPool, + filteredPools: filteredPools?.Select(f => f.Item1).ToList() ?? new List() + ), errorResponses); + } + + static (Generated.MatchHosting, List) FromCoreMatchHosting( + Core.IMatchHostingConfig matchHostingConfig, + Core.MultiplayResources availableMultiplayResources, + bool dryRun) + { + var errors = new List(); + if (matchHostingConfig is Core.MultiplayConfig multiplayConfig) + { + var fleet = availableMultiplayResources.Fleets.Find(f => f.Name == multiplayConfig.FleetName); + if (fleet.Name == null) + { + errors.Add( + new Core.ErrorResponse() + { + ResultCode = "InvalidMultiplayFleetName", + Message = $"Fleet named '{multiplayConfig.FleetName}' not found." + }); + } + else + { + var qosRegion = fleet.QosRegions.Find(r => r.Name == multiplayConfig.DefaultQoSRegionName); + var buildConfig = fleet.BuildConfigs.Find(b => b.Name == multiplayConfig.BuildConfigurationName); + if (buildConfig.Name == null) + { + errors.Add( + new Core.ErrorResponse() + { + ResultCode = "InvalidBuildConfigurationName", + Message = $"Build configuration named '{multiplayConfig.BuildConfigurationName}' not found in fleet named '{multiplayConfig.FleetName}'." + }); + } + else if (qosRegion.Name == null) + { + errors.Add( + new Core.ErrorResponse() + { + ResultCode = "InvalidDefaultQoSRegion", + Message = $"QoS region named '{multiplayConfig.DefaultQoSRegionName}' not found for fleet named '{multiplayConfig.FleetName}'." + }); + } + else + { + if (!dryRun) + { + return (new Generated.MatchHosting( + new Generated.MultiplayHostingConfig( + type: Generated.MultiplayHostingConfig.TypeEnum.Multiplay, + fleetId: fleet.Id, + buildConfigurationId: buildConfig.Id, + defaultQoSRegionId: qosRegion.Id + )), errors); + } + } + } + } + return (new Generated.MatchHosting(new Generated.MatchIdHostingConfig(type: Generated.MatchIdHostingConfig.TypeEnum.MatchId)), errors); + } + + static Generated.Rule FromCoreRule(Core.Rule rule) + { + Generated.Rule.TypeEnum type = rule.type switch + { + Core.RuleType.GreaterThan => Generated.Rule.TypeEnum.GreaterThan, + Core.RuleType.GreaterThanEqual => Generated.Rule.TypeEnum.GreaterThanEqual, + Core.RuleType.LessThan => Generated.Rule.TypeEnum.LessThan, + Core.RuleType.LessThanEqual => Generated.Rule.TypeEnum.LessThanEqual, + Core.RuleType.Difference => Generated.Rule.TypeEnum.Difference, + Core.RuleType.DoubleDifference => Generated.Rule.TypeEnum.DoubleDifference, + Core.RuleType.Equality => Generated.Rule.TypeEnum.Equality, + Core.RuleType.InList => Generated.Rule.TypeEnum.InList, + Core.RuleType.Intersection => Generated.Rule.TypeEnum.Intersection, + _ => throw new InvalidEnumArgumentException(nameof(type)) + }; + + Generated.RuleExternalDataCloudSave.AccessClassEnum accessClass = + rule.externalData?.cloudSave?.accessClass switch + { + Core.RuleExternalData.CloudSave.AccessClass.Public => Generated.RuleExternalDataCloudSave + .AccessClassEnum.Public, + Core.RuleExternalData.CloudSave.AccessClass.Private => Generated.RuleExternalDataCloudSave + .AccessClassEnum.Private, + Core.RuleExternalData.CloudSave.AccessClass.Protected => Generated.RuleExternalDataCloudSave + .AccessClassEnum.Protected, + _ => Generated.RuleExternalDataCloudSave.AccessClassEnum.Default, + }; + + var generatedRule = new Generated.Rule( + source: rule.source, + name: rule.name, + type: type, + enableRule: rule.enableRule, + not: rule.not, + reference: rule.reference, + overlap: Convert.ToDecimal(rule.overlap, CultureInfo.InvariantCulture), + relaxations: rule.relaxations?.Select(FromCoreRuleRelaxation).ToList() ?? + new List() + ); + + if (rule.externalData != null) + { + generatedRule.ExternalData = new Generated.RuleExternalData(); + if (rule.externalData.leaderboard != null) + { + generatedRule.ExternalData.Leaderboard = + new Generated.RuleExternalDataLeaderboard(rule.externalData.leaderboard.id); + } + + if (rule.externalData.cloudSave != null) + { + generatedRule.ExternalData.CloudSave = new Generated.RuleExternalDataCloudSave( + _default: rule.externalData.cloudSave._default, + accessClass: accessClass); + } + } + + return generatedRule; + } + + static Generated.AgeType FromCoreAgeType(Core.AgeType ageType) + { + return ageType switch + { + Core.AgeType.Average => Generated.AgeType.Average, + Core.AgeType.Oldest => Generated.AgeType.Oldest, + Core.AgeType.Youngest => Generated.AgeType.Youngest, + _ => throw new InvalidEnumArgumentException(nameof(ageType)) + }; + } + + static Generated.RuleRelaxation FromCoreRuleRelaxation(Core.RuleRelaxation ruleRelaxation) + { + Generated.RuleRelaxation.TypeEnum type = ruleRelaxation.type switch + { + Core.RuleRelaxationType.ReferenceControlReplace => + Generated.RuleRelaxation.TypeEnum.ReferenceControlReplace, + Core.RuleRelaxationType.RuleControlDisable => Generated.RuleRelaxation.TypeEnum.RuleControlDisable, + Core.RuleRelaxationType.RuleControlEnable => Generated.RuleRelaxation.TypeEnum.RuleControlEnable, + _ => throw new InvalidEnumArgumentException(nameof(type)) + }; + + return new Generated.RuleRelaxation( + type: type, + atSeconds: ruleRelaxation.atSeconds, + ageType: FromCoreAgeType(ruleRelaxation.ageType), + value: ruleRelaxation.value + ); + } + + static Generated.RangeRelaxation FromCoreRangeRelaxation(Core.RangeRelaxation rangeRelaxation) + { + return new Generated.RangeRelaxation( + type: Generated.RangeRelaxation.TypeEnum.RangeControlReplaceMin, + atSeconds: rangeRelaxation.atSeconds, + ageType: FromCoreAgeType(rangeRelaxation.ageType), + value: rangeRelaxation.value + ); + } + + static Generated.RuleBasedMatchDefinition FromCoreRuleBasedMatchDefinition( + Core.RuleBasedMatchDefinition ruleBasedMatchDefinition) + { + return new Generated.RuleBasedMatchDefinition( + teams: ruleBasedMatchDefinition.teams?.Select( + team => new Generated.RuleBasedTeamDefinition( + name: team.name, + teamCount: new Generated.Range( + min: team.teamCount.min, + max: team.teamCount.max, + relaxations: team.teamCount.relaxations?.Select(FromCoreRangeRelaxation).ToList() ?? + new List() + ), + playerCount: new Generated.Range( + min: team.playerCount.min, + max: team.playerCount.max, + relaxations: team.playerCount.relaxations?.Select(FromCoreRangeRelaxation).ToList() ?? + new List() + ), + teamRules: team.teamRules?.Select(FromCoreRule).ToList() ?? new List()) + ) + .ToList() ?? new List(), + matchRules: ruleBasedMatchDefinition.matchRules?.Select(FromCoreRule).ToList() ?? + new List() + ); + } + + static Generated.Rules FromCoreMatchLogic(Core.MatchLogicRulesConfig matchRules) + { + return new Generated.Rules( + name: matchRules.Name, + backfillEnabled: matchRules.BackfillEnabled, + matchDefinition: FromCoreRuleBasedMatchDefinition(matchRules.MatchDefinition) + ); + } + + static (Generated.PoolConfig, List) FromCorePoolConfig( + Core.PoolConfig poolConfig, + Core.MultiplayResources availableMultiplayResources, + bool dryRun) + { + var matchHosting = FromCoreMatchHosting(poolConfig.MatchHosting, availableMultiplayResources, dryRun); + return (new Generated.PoolConfig( + name: poolConfig.Name.ToString() ?? string.Empty, + enabled: poolConfig.Enabled, + timeoutSeconds: poolConfig.TimeoutSeconds, + matchLogic: FromCoreMatchLogic(poolConfig.MatchLogic), + matchHosting: matchHosting.Item1 + ), matchHosting.Item2); + } + + static (Generated.FilteredPoolConfig, List) FromCoreFilteredPoolConfig( + Core.FilteredPoolConfig poolConfig, + Core.MultiplayResources availableMultiplayResources, + bool dryRun) + { + var (matchHosting, errors) = FromCoreMatchHosting( + poolConfig.MatchHosting, + availableMultiplayResources, + dryRun); + + var variants = poolConfig.Variants?.Select(p => FromCorePoolConfig(p, availableMultiplayResources, dryRun)).ToList(); + errors.AddRange(variants?.Select(v => v.Item2).SelectMany(e => e).ToList() ?? new List()); + + return (new Generated.FilteredPoolConfig( + name: poolConfig.Name.ToString() ?? string.Empty, + enabled: poolConfig.Enabled, + timeoutSeconds: poolConfig.TimeoutSeconds, + matchLogic: FromCoreMatchLogic(poolConfig.MatchLogic), + matchHosting: matchHosting, + variants: variants?.Select(v => v.Item1).ToList(), + filters: poolConfig.Filters?.Select( + f => + { + Generated.Filter.OperatorEnum filter = f.Operator switch + { + Core.FilteredPoolConfig.Filter.FilterOperator.GreaterThan => Generated.Filter + .OperatorEnum + .GreaterThan, + Core.FilteredPoolConfig.Filter.FilterOperator.LessThan => Generated.Filter.OperatorEnum + .LessThan, + Core.FilteredPoolConfig.Filter.FilterOperator.NotEqual => Generated.Filter.OperatorEnum + .NotEqual, + Core.FilteredPoolConfig.Filter.FilterOperator.Equal => Generated.Filter.OperatorEnum + .Equal, + _ => throw new InvalidEnumArgumentException(nameof(f.Operator)) + }; + + return new Generated.Filter( + attribute: f.Attribute, + _operator: filter, + value: f.Value + ); + }) + .ToList() ?? new List() + ), errors); + } + + static (Generated.BasePoolConfig?, List) FromCoreBasePoolConfig( + Core.BasePoolConfig? poolConfig, + Core.MultiplayResources availableMultiplayResources, + bool dryRun) + { + if (poolConfig == null) + { + return (null, new List()); + } + var (matchHosting, errors) = FromCoreMatchHosting( + poolConfig.MatchHosting, + availableMultiplayResources, + dryRun); + + var variants = poolConfig.Variants?.Select(p => FromCorePoolConfig(p, availableMultiplayResources, dryRun)).ToList(); + errors.AddRange(variants?.Select(v => v.Item2).SelectMany(e => e).ToList() ?? new List()); + + return (new Generated.BasePoolConfig( + name: poolConfig.Name.ToString() ?? string.Empty, + enabled: poolConfig.Enabled, + timeoutSeconds: poolConfig.TimeoutSeconds, + matchLogic: FromCoreMatchLogic(poolConfig.MatchLogic), + matchHosting: matchHosting, + variants: variants?.Select(v => v.Item1).ToList() ?? new List() + ), errors); + } +} + diff --git a/Unity.Services.Cli/Unity.Services.Cli.Matchmaker/AdminApiClient/ModelGeneratedToCore.cs b/Unity.Services.Cli/Unity.Services.Cli.Matchmaker/AdminApiClient/ModelGeneratedToCore.cs new file mode 100644 index 0000000..8415cc2 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Matchmaker/AdminApiClient/ModelGeneratedToCore.cs @@ -0,0 +1,298 @@ +using System.ComponentModel; +using Newtonsoft.Json; +using Core = Unity.Services.Matchmaker.Authoring.Core.Model; +using Generated = Unity.Services.Gateway.MatchmakerAdminApiV3.Generated.Model; + +namespace Unity.Services.Cli.Matchmaker.AdminApiClient; + +static class ModelGeneratedToCore +{ + static Core.Rule FromGeneratedRule(Generated.Rule rule) + { + Core.RuleType type = rule.Type switch + { + Generated.Rule.TypeEnum.GreaterThan => Core.RuleType.GreaterThan, + Generated.Rule.TypeEnum.GreaterThanEqual => Core.RuleType.GreaterThanEqual, + Generated.Rule.TypeEnum.LessThan => Core.RuleType.LessThan, + Generated.Rule.TypeEnum.LessThanEqual => Core.RuleType.LessThanEqual, + Generated.Rule.TypeEnum.Difference => Core.RuleType.Difference, + Generated.Rule.TypeEnum.DoubleDifference => Core.RuleType.DoubleDifference, + Generated.Rule.TypeEnum.Equality => Core.RuleType.Equality, + Generated.Rule.TypeEnum.InList => Core.RuleType.InList, + Generated.Rule.TypeEnum.Intersection => Core.RuleType.Intersection, + _ => throw new InvalidEnumArgumentException(nameof(type)) + }; + + Core.RuleExternalData.CloudSave.AccessClass accessClass = rule.ExternalData?.CloudSave?.AccessClass switch + { + Generated.RuleExternalDataCloudSave.AccessClassEnum.Public => Core.RuleExternalData.CloudSave.AccessClass.Public, + Generated.RuleExternalDataCloudSave.AccessClassEnum.Private => Core.RuleExternalData.CloudSave.AccessClass.Private, + Generated.RuleExternalDataCloudSave.AccessClassEnum.Protected => Core.RuleExternalData.CloudSave.AccessClass.Protected, + _ => Core.RuleExternalData.CloudSave.AccessClass.Default, + }; + + Core.RuleExternalData? externalData = null; + if (rule.ExternalData != null) + { + externalData = new Core.RuleExternalData(); + + if (rule.ExternalData.Leaderboard != null) + { + externalData.leaderboard = new Core.RuleExternalData.Leaderboard + { + id = rule.ExternalData.Leaderboard.Id + }; + } + + if (rule.ExternalData.CloudSave != null) + { + externalData.cloudSave = new Core.RuleExternalData.CloudSave() + { + accessClass = accessClass, + _default = new MatchmakerAdminClient.JsonObjectSpecialized( + JsonConvert.SerializeObject(rule.ExternalData.CloudSave.Default)) + }; + } + } + return new Core.Rule + { + source = rule.Source, + name = rule.Name, + type = type, + enableRule = rule.EnableRule, + not = rule.Not, + reference = new MatchmakerAdminClient.JsonObjectSpecialized(JsonConvert.SerializeObject(rule.Reference)), + overlap = Decimal.ToDouble(rule.Overlap), + relaxations = rule.Relaxations?.Select( + rlx => + { + Core.RuleRelaxationType rlxType = rlx.Type switch + { + Generated.RuleRelaxation.TypeEnum.ReferenceControlReplace => Core.RuleRelaxationType.ReferenceControlReplace, + Generated.RuleRelaxation.TypeEnum.RuleControlDisable => Core.RuleRelaxationType.RuleControlDisable, + Generated.RuleRelaxation.TypeEnum.RuleControlEnable => Core.RuleRelaxationType.RuleControlEnable, + _ => throw new InvalidEnumArgumentException(nameof(rlxType)) + }; + + var coreRuleRelaxation = new Core.RuleRelaxation + { + type = rlxType, + atSeconds = rlx.AtSeconds, + ageType = FromGeneratedAgeType(rlx.AgeType) + }; + + if (rlx.Value != null) + { + coreRuleRelaxation.value = new MatchmakerAdminClient.JsonObjectSpecialized(JsonConvert.SerializeObject(rlx.Value)); + } + + return coreRuleRelaxation; + }) + .ToList() ?? new List(), + externalData = externalData + }; + } + + static Core.RangeRelaxation FromGeneratedRangeRelaxation(Generated.RangeRelaxation rangeRelaxation) + { + return new Core.RangeRelaxation() + { + type = Core.RangeRelaxationType.RangeControlReplaceMin, + atSeconds = rangeRelaxation.AtSeconds, + ageType = FromGeneratedAgeType(rangeRelaxation.AgeType), + value = rangeRelaxation.Value + }; + } + + static Core.AgeType FromGeneratedAgeType(Generated.AgeType ageType) + { + return ageType switch + { + Generated.AgeType.Average => Core.AgeType.Average, + Generated.AgeType.Oldest => Core.AgeType.Oldest, + Generated.AgeType.Youngest => Core.AgeType.Youngest, + _ => throw new InvalidEnumArgumentException(nameof(ageType)) + }; + } + + static Core.RuleBasedMatchDefinition FromGeneratedRuleBase(Generated.RuleBasedMatchDefinition matchDefinition) + { + return new Core.RuleBasedMatchDefinition + { + matchRules = matchDefinition.MatchRules?.Select(FromGeneratedRule).ToList() ?? new List(), + teams = matchDefinition.Teams?.Select( + team => new Core.RuleBasedTeamDefinition + { + name = team.Name, + playerCount = new Core.Range + { + min = team.PlayerCount.Min, + max = team.PlayerCount.Max, + relaxations = team.PlayerCount.Relaxations?.Select(FromGeneratedRangeRelaxation).ToList() ?? new List() + }, + teamCount = new Core.Range + { + min = team.TeamCount.Min, + max = team.TeamCount.Max, + relaxations = team.TeamCount.Relaxations?.Select(FromGeneratedRangeRelaxation).ToList() ?? new List() + }, + teamRules = team.TeamRules?.Select(FromGeneratedRule).ToList() ?? new List() + }) + .ToList() ?? new List() + }; + } + + static Core.MatchLogicRulesConfig FromGeneratedMatchLogic(Generated.Rules matchLogic) + { + return new Core.MatchLogicRulesConfig + { + Name = matchLogic.Name, + BackfillEnabled = matchLogic.BackfillEnabled, + MatchDefinition = FromGeneratedRuleBase(matchLogic.MatchDefinition) + }; + } + + static (Core.IMatchHostingConfig, List) FromGeneratedHostingConfig(Generated.MatchHosting matchHosting, Core.MultiplayResources availableMultiplayResources) + { + Core.IMatchHostingConfig matchHostingConfig; + var errors = new List(); + var buildName = string.Empty; + var regionName = string.Empty; + if (matchHosting.ActualInstance is Generated.MultiplayHostingConfig multiplayConfig) + { + var fleet = availableMultiplayResources.Fleets.Find(f => f.Id == multiplayConfig.FleetId); + if (fleet.Name == null) + { + errors.Add( + new Core.ErrorResponse() + { + ResultCode = "InvalidMultiplayFleetId", + Message = $"Fleet with id '{multiplayConfig.FleetId}' not found." + }); + } + else + { + regionName = fleet.QosRegions.Find(r => r.Id == multiplayConfig.DefaultQoSRegionId).Name; + buildName = fleet.BuildConfigs.Find(b => b.Id == multiplayConfig.BuildConfigurationId).Name; + if (buildName == null) + { + errors.Add( + new Core.ErrorResponse() + { + ResultCode = "InvalidBuildConfigurationId", + Message = $"Build configuration with id '{multiplayConfig.BuildConfigurationId}' not found in fleet named '{fleet.Name}'." + }); + } + else if (regionName == null) + { + errors.Add( + new Core.ErrorResponse() + { + ResultCode = "InvalidDefaultQoSRegion", + Message = $"QoS region named '{multiplayConfig.DefaultQoSRegionId}' not found for fleet named '{fleet.Name}'." + }); + } + } + matchHostingConfig = new Core.MultiplayConfig + { + Type = Core.IMatchHostingConfig.MatchHostingType.Multiplay, + FleetName = fleet.Name, + BuildConfigurationName = buildName, + DefaultQoSRegionName = regionName + }; + } + else + { + matchHostingConfig = new Core.MatchIdConfig() + { + Type = Core.IMatchHostingConfig.MatchHostingType.MatchId + }; + } + + return (matchHostingConfig, errors); + } + + internal static (Core.QueueConfig, List) FromGeneratedQueueConfig(Generated.QueueConfig queueConfig, Core.MultiplayResources availableMultiplayResources) + { + var (defaultPool, errors) = FromGeneratedBasePoolConfig(queueConfig.DefaultPool, availableMultiplayResources); + var filteredPools = queueConfig.FilteredPools?.Select(v => FromGeneratedFilteredPoolConfig(v, availableMultiplayResources)).ToList(); + errors.AddRange(filteredPools?.Select(v => v.Item2).SelectMany(e => e) ?? new List()); + return (new Core.QueueConfig + { + Name = new Core.QueueName(queueConfig.Name), + Enabled = queueConfig.Enabled, + MaxPlayersPerTicket = queueConfig.MaxPlayersPerTicket, + DefaultPool = defaultPool, + FilteredPools = filteredPools?.Select(v => v.Item1).ToList() ?? new List(), + }, errors); + } + + static (Core.BasePoolConfig?, List) FromGeneratedBasePoolConfig(Generated.BasePoolConfig? poolConfig, Core.MultiplayResources availableMultiplayResources) + { + if (poolConfig == null) + { + return (null, new List()); + } + var (matchHosting, errors) = FromGeneratedHostingConfig(poolConfig.MatchHosting, availableMultiplayResources); + var variants = poolConfig.Variants?.Select(v => FromGeneratedPoolConfig(v, availableMultiplayResources)).ToList(); + errors.AddRange(variants?.Select(v => v.Item2).SelectMany(e => e) ?? new List()); + return (new Core.BasePoolConfig + { + Name = new Core.PoolName(poolConfig.Name), + Enabled = poolConfig.Enabled, + MatchLogic = FromGeneratedMatchLogic(poolConfig.MatchLogic), + MatchHosting = matchHosting, + TimeoutSeconds = poolConfig.TimeoutSeconds, + Variants = variants?.Select(v => v.Item1).ToList() ?? new List(), + }, errors); + } + + static (Core.PoolConfig, List) FromGeneratedPoolConfig(Generated.PoolConfig poolConfig, Core.MultiplayResources availableMultiplayResources) + { + var (matchHosting, errors) = FromGeneratedHostingConfig(poolConfig.MatchHosting, availableMultiplayResources); + return (new Core.PoolConfig + { + Name = new Core.PoolName(poolConfig.Name), + Enabled = poolConfig.Enabled, + MatchLogic = FromGeneratedMatchLogic(poolConfig.MatchLogic), + MatchHosting = matchHosting, + TimeoutSeconds = poolConfig.TimeoutSeconds + }, errors); + } + + static (Core.FilteredPoolConfig, List) FromGeneratedFilteredPoolConfig(Generated.FilteredPoolConfig poolConfig, Core.MultiplayResources availableMultiplayResources) + { + var (matchHosting, errors) = FromGeneratedHostingConfig(poolConfig.MatchHosting, availableMultiplayResources); + var variants = poolConfig.Variants?.Select(v => FromGeneratedPoolConfig(v, availableMultiplayResources)).ToList(); + errors.AddRange(variants?.Select(v => v.Item2).SelectMany(e => e) ?? new List()); + return (new Core.FilteredPoolConfig + { + Name = new Core.PoolName(poolConfig.Name), + Enabled = poolConfig.Enabled, + MatchLogic = FromGeneratedMatchLogic(poolConfig.MatchLogic), + MatchHosting = matchHosting, + TimeoutSeconds = poolConfig.TimeoutSeconds, + Variants = variants?.Select(v => v.Item1).ToList() ?? new List(), + Filters = poolConfig.Filters?.Select( + f => + { + Core.FilteredPoolConfig.Filter.FilterOperator filter = f.Operator switch + { + Generated.Filter.OperatorEnum.GreaterThan => Core.FilteredPoolConfig.Filter.FilterOperator.GreaterThan, + Generated.Filter.OperatorEnum.LessThan => Core.FilteredPoolConfig.Filter.FilterOperator.LessThan, + Generated.Filter.OperatorEnum.NotEqual => Core.FilteredPoolConfig.Filter.FilterOperator.NotEqual, + Generated.Filter.OperatorEnum.Equal => Core.FilteredPoolConfig.Filter.FilterOperator.Equal, + _ => throw new InvalidEnumArgumentException(nameof(filter)) + }; + + return new Core.FilteredPoolConfig.Filter + { + Attribute = f.Attribute, + Operator = filter, + Value = new MatchmakerAdminClient.JsonObjectSpecialized(JsonConvert.SerializeObject(f.Value)) + }; + }) + .ToList() ?? new List() + }, errors); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Matchmaker/MatchmakerModule.cs b/Unity.Services.Cli/Unity.Services.Cli.Matchmaker/MatchmakerModule.cs new file mode 100644 index 0000000..a4d3281 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Matchmaker/MatchmakerModule.cs @@ -0,0 +1,56 @@ +using System.CommandLine; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Unity.Services.Cli.Authoring.Handlers; +using Unity.Services.Cli.Authoring.Service; +using Unity.Services.Cli.Common; +using Unity.Services.Cli.Common.Networking; +using Unity.Services.Cli.Matchmaker.Parser; +using Unity.Services.Cli.Matchmaker.Service; +using Unity.Services.Gateway.MatchmakerAdminApiV3.Generated.Api; +using Unity.Services.Matchmaker.Authoring.Core.ConfigApi; +using Unity.Services.Matchmaker.Authoring.Core.Deploy; +using Unity.Services.Matchmaker.Authoring.Core.Fetch; +using Unity.Services.Matchmaker.Authoring.Core.IO; +using Unity.Services.Matchmaker.Authoring.Core.Parser; + +namespace Unity.Services.Cli.Matchmaker; + +/// +/// A Template module to achieve a get request command: ugs matchmaker get `address` -o `file` +/// +public class MatchmakerModule : ICommandModule +{ + public Command? ModuleRootCommand { get; } + + public MatchmakerModule() + { + ModuleRootCommand = new Command("matchmaker", "Matchmaker module root command.") + { + ModuleRootCommand.AddNewFileCommand("matchmaker queue") + }; + } + + /// + /// Register service to UGS CLI host builder + /// + public static void RegisterServices(HostBuilderContext context, IServiceCollection serviceCollection) + { + var config = new Gateway.MatchmakerAdminApiV3.Generated.Client.Configuration + { + BasePath = EndpointHelper.GetCurrentEndpointFor() + }; + config.DefaultHeaders.SetXClientIdHeader(); + + serviceCollection.AddSingleton(new MatchmakerAdminApi(config)); + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Matchmaker/Parser/DataMemberEnumConverter.cs b/Unity.Services.Cli/Unity.Services.Cli.Matchmaker/Parser/DataMemberEnumConverter.cs new file mode 100644 index 0000000..ebcd55a --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Matchmaker/Parser/DataMemberEnumConverter.cs @@ -0,0 +1,75 @@ +using System.Runtime.Serialization; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace Unity.Services.Cli.Matchmaker.Parser; + +// Enum may come with DataMember Name property. If it's the case serialize/deserialize that value instead. +public class DataMemberEnumConverter : StringEnumConverter +{ + public override bool CanConvert(Type objectType) + { + return objectType.IsEnum; + } + + public override object ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) + { + if (reader == null || objectType == null) + { + throw new ArgumentNullException(objectType == null ? nameof(objectType) : nameof(reader)); + } + + var enumString = reader.Value as string; + if (enumString == null) + { + throw new ArgumentNullException(nameof(reader.Value)); + } + + foreach (var name in Enum.GetNames(objectType)) + { + var fieldInfo = objectType.GetField(name); + if (fieldInfo == null) + { + continue; + } + + var enumMemberAttribute = ((DataMemberAttribute[])fieldInfo.GetCustomAttributes(typeof(DataMemberAttribute), true)).SingleOrDefault(); + if (enumMemberAttribute != null && enumMemberAttribute.Name == enumString) + { + return Enum.Parse(objectType, name); + } + } + return base.ReadJson(reader, objectType, existingValue, serializer) ?? throw new ArgumentNullException(nameof(reader)); + } + + public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) + { + if (writer == null || value == null) + { + throw new ArgumentNullException(value == null ? nameof(value) : nameof(writer)); + } + + var enumType = value.GetType(); + var name = Enum.GetName(enumType, value); + if (name == null) + { + throw new ArgumentNullException(nameof(value)); + } + + var fieldInfo = enumType.GetField(name); + if (fieldInfo == null) + { + throw new ArgumentNullException(nameof(fieldInfo)); + } + + var enumMemberAttribute = ((DataMemberAttribute[])fieldInfo.GetCustomAttributes(typeof(DataMemberAttribute), true)).SingleOrDefault(); + if (enumMemberAttribute != null) + { + writer.WriteValue(enumMemberAttribute.Name); + } + else + { + writer.WriteValue(name); + } + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Matchmaker/Parser/HostingConfigTypeConverter.cs b/Unity.Services.Cli/Unity.Services.Cli.Matchmaker/Parser/HostingConfigTypeConverter.cs new file mode 100644 index 0000000..4f975ee --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Matchmaker/Parser/HostingConfigTypeConverter.cs @@ -0,0 +1,30 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Unity.Services.Matchmaker.Authoring.Core.Model; +namespace Unity.Services.Cli.Matchmaker.Parser; + +public class MatchHostingConfigTypeConverted : JsonConverter +{ + public override bool CanConvert(Type objectType) + { + return objectType == typeof(IMatchHostingConfig); + } + + public override object ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) + { + JObject item = JObject.Load(reader); + var type = item["type"]?.Value() ?? "Unspecified"; + switch (type) + { + case "Multiplay": + return item.ToObject(serializer) ?? throw new InvalidOperationException(); + case "MatchId": + return item.ToObject(serializer) ?? throw new InvalidOperationException(); + default: + throw new JsonSerializationException($"Invalid hosting config type: {type}"); + } + } + + // Interface cannot be serialized + public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) { } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Matchmaker/Parser/JsonObjectSpecializedConverter.cs b/Unity.Services.Cli/Unity.Services.Cli.Matchmaker/Parser/JsonObjectSpecializedConverter.cs new file mode 100644 index 0000000..5cdec23 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Matchmaker/Parser/JsonObjectSpecializedConverter.cs @@ -0,0 +1,38 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Unity.Services.Matchmaker.Authoring.Core.Model; + +namespace Unity.Services.Cli.Matchmaker.Parser; + +/// +/// This converter stores the raw json string for complex object that we don't care to deserialize and the client generator can't handle +/// It can take a json object, array, string or number literal and output it back without messing the type +/// +class JsonObjectSpecializedConverter : JsonConverter +{ + public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) + { + if (value is JsonObject valueJson) + { + var obj = JToken.Parse(valueJson.Value); + obj.WriteTo(writer); + } + } + + public override object? ReadJson( + JsonReader reader, + Type objectType, + object? existingValue, + JsonSerializer serializer) + { + var obj = JToken.ReadFrom(reader); + if (obj.Type == JTokenType.String) + return new AdminApiClient.MatchmakerAdminClient.JsonObjectSpecialized("\"" + obj + "\""); + return new AdminApiClient.MatchmakerAdminClient.JsonObjectSpecialized(obj.ToString()); + } + + public override bool CanConvert(Type objectType) + { + return objectType.IsAssignableTo(typeof(JsonObject)); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Matchmaker/Parser/MatchmakerConfigParser.cs b/Unity.Services.Cli/Unity.Services.Cli.Matchmaker/Parser/MatchmakerConfigParser.cs new file mode 100644 index 0000000..a2213b3 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Matchmaker/Parser/MatchmakerConfigParser.cs @@ -0,0 +1,200 @@ +using System.Reflection; +using System.Runtime.Serialization; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Newtonsoft.Json.Serialization; +using Unity.Services.DeploymentApi.Editor; +using Unity.Services.Matchmaker.Authoring.Core.Fetch; +using Unity.Services.Matchmaker.Authoring.Core.IO; +using Unity.Services.Matchmaker.Authoring.Core.Model; +using Unity.Services.Matchmaker.Authoring.Core.Parser; + +namespace Unity.Services.Cli.Matchmaker.Parser; + +class MatchmakerConfigParser : IMatchmakerConfigParser, IDeepEqualityComparer +{ + readonly IFileSystem m_FileSystem; + + public class CustomContractResolver : CamelCasePropertyNamesContractResolver + { + protected override JsonProperty CreateProperty(MemberInfo member, MemberSerialization memberSerialization) + { + JsonProperty property = base.CreateProperty(member, memberSerialization); + + // Check if a DataMember attribute is present + var dataMemberAttribute = member.GetCustomAttribute(); + if (dataMemberAttribute != null) + { + // If a Name has been provided, use it + if (!string.IsNullOrEmpty(dataMemberAttribute.Name)) + { + property.PropertyName = dataMemberAttribute.Name; + } + + if (dataMemberAttribute.IsRequired) + { + property.Required = Required.Always; + } + } + return property; + } + } + + public static readonly JsonSerializerSettings JsonSerializerSettings = new() + { + Formatting = Formatting.Indented, + ContractResolver = new CustomContractResolver(), + NullValueHandling = NullValueHandling.Ignore, + Converters = new List + { + new DataMemberEnumConverter(), + new ResourceNameConverter(), + new MatchHostingConfigTypeConverted(), + new JsonObjectSpecializedConverter(), + } + }; + + public MatchmakerConfigParser(IFileSystem fileSystem) + { + m_FileSystem = fileSystem; + } + + public async Task Parse(IReadOnlyList filePaths, + CancellationToken ct) + { + var result = new IMatchmakerConfigParser.ParsingResult(); + var queueNames = new List(); + var environmentConfigPresent = false; + + foreach (var filePath in filePaths) + { + string fileText; + MatchmakerConfigResource mmConfigFile = new MatchmakerConfigResource + { + Name = "", + Path = filePath, + }; + try + { + fileText = await m_FileSystem.ReadAllText(filePath, ct); + } + catch (FileSystemException ex) + { + mmConfigFile.Status = new DeploymentStatus($"Failed to read file {filePath}", ex.ToString(), SeverityLevel.Error); + result.failed.Add(mmConfigFile); + continue; + } + + try + { + switch (Path.GetExtension(filePath)) + { + case IMatchmakerConfigParser.QueueConfigExtension: + var queueConfig = JsonConvert.DeserializeObject(fileText, JsonSerializerSettings); + mmConfigFile.Content = queueConfig; + if (queueConfig == null) + { + break; + } + if (queueNames.Contains(queueConfig.Name.ToString())) + { + mmConfigFile.Status = new DeploymentStatus( + $"Multiple queue config files named {queueConfig.Name} found", + $"Multiple queue config files named {queueConfig.Name} found in {filePath}", + SeverityLevel.Error); + result.failed.Add(mmConfigFile); + continue; + } + + queueNames.Add(queueConfig.Name.ToString()); + mmConfigFile.Name = queueConfig.Name.ToString(); + + break; + case IMatchmakerConfigParser.EnvironmentConfigExtension: + mmConfigFile.Content = JsonConvert.DeserializeObject(fileText, JsonSerializerSettings); + if (environmentConfigPresent) + { + mmConfigFile.Status = new DeploymentStatus($"Multiple environment config files found", $"Multiple environment config files found in {filePath}", SeverityLevel.Error); + result.failed.Add(mmConfigFile); + continue; + } + environmentConfigPresent = true; + mmConfigFile.Name = "EnvironmentConfig"; + break; + } + + if (mmConfigFile.Content == null) + { + // This can only happen for empty file. Other case either work or throw exception + mmConfigFile.Status = new DeploymentStatus($"Invalid matchmaker config file", "Is the file empty ?", SeverityLevel.Error); + result.failed.Add(mmConfigFile); + continue; + } + result.parsed.Add(mmConfigFile); + } + catch (JsonSerializationException ex) + { + mmConfigFile.Status = new DeploymentStatus($"Invalid values in file {filePath}", $"Invalid values in {filePath} at line {ex.LineNumber}: {ex.Message}", SeverityLevel.Error); + result.failed.Add(mmConfigFile); + } + catch (JsonException ex) + { + mmConfigFile.Status = new DeploymentStatus($"Invalid json in file {filePath}", $"Invalid json in file {filePath}: {ex.Message}", SeverityLevel.Error); + result.failed.Add(mmConfigFile); + } + } + + return result; + } + + public async Task<(bool, string)> SerializeToFile(IMatchmakerConfig config, string path, CancellationToken ct) + { + var targetJson = string.Empty; + var originalJson = string.Empty; + try + { + originalJson = await m_FileSystem.ReadAllText(path, ct); + } + catch (IOException) + { + // if no original file, just write the new one + } + + switch (config) + { + case QueueConfig queueConfig: + targetJson = JsonConvert.SerializeObject(queueConfig, JsonSerializerSettings); + break; + case EnvironmentConfig environmentConfig: + targetJson = JsonConvert.SerializeObject(environmentConfig, JsonSerializerSettings); + break; + } + + targetJson += "\n"; // add newline at end of file + + try + { + if (targetJson == string.Empty || originalJson == targetJson) + return (false, string.Empty); + await m_FileSystem.WriteAllText(path, targetJson, ct); + } + catch (FileSystemException ex) + { + return (false, ex.ToString()); + } + + return (true, string.Empty); + } + + public bool IsDeepEqual(T source, T target) + { + if (source == null || target == null) + { + return source == null && target == null; + } + + var sourceJson = JsonConvert.SerializeObject(source, JsonSerializerSettings); + var targetJson = JsonConvert.SerializeObject(target, JsonSerializerSettings); + return sourceJson == targetJson; + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Matchmaker/Parser/ResourceNameConverter.cs b/Unity.Services.Cli/Unity.Services.Cli.Matchmaker/Parser/ResourceNameConverter.cs new file mode 100644 index 0000000..f19940e --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Matchmaker/Parser/ResourceNameConverter.cs @@ -0,0 +1,22 @@ +using Newtonsoft.Json; +using Unity.Services.Matchmaker.Authoring.Core.Model; + +namespace Unity.Services.Cli.Matchmaker.Parser; + +public class ResourceNameConverter : JsonConverter +{ + public override bool CanConvert(Type objectType) + { + return objectType.IsSubclassOf(typeof(ResourceName)); + } + + public override object ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) + { + return Activator.CreateInstance(objectType, reader.Value) ?? throw new InvalidOperationException(); + } + + public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) + { + writer.WriteValue(value?.ToString()); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Matchmaker/Service/AdminApiTargetEndpoint.cs b/Unity.Services.Cli/Unity.Services.Cli.Matchmaker/Service/AdminApiTargetEndpoint.cs new file mode 100644 index 0000000..2a7e181 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Matchmaker/Service/AdminApiTargetEndpoint.cs @@ -0,0 +1,13 @@ +using Unity.Services.Cli.Common.Networking; + +namespace Unity.Services.Cli.Matchmaker.Service; + +public class AdminApiTargetEndpoint : NetworkTargetEndpoints +{ + + public AdminApiTargetEndpoint() { } + + 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.Matchmaker/Service/IMatchmakerService.cs b/Unity.Services.Cli/Unity.Services.Cli.Matchmaker/Service/IMatchmakerService.cs new file mode 100644 index 0000000..1121ec4 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Matchmaker/Service/IMatchmakerService.cs @@ -0,0 +1,20 @@ +using Unity.Services.Matchmaker.Authoring.Core.Model; +using EnvironmentConfig = Unity.Services.Gateway.MatchmakerAdminApiV3.Generated.Model.EnvironmentConfig; +using QueueConfig = Unity.Services.Gateway.MatchmakerAdminApiV3.Generated.Model.QueueConfig; + +namespace Unity.Services.Cli.Matchmaker.Service; + +public interface IMatchmakerService +{ + Task Initialize(string projectId, string environmentId, CancellationToken ct = default); + + Task<(bool, EnvironmentConfig)> GetEnvironmentConfig(CancellationToken ct = default); + + Task> UpsertEnvironmentConfig(EnvironmentConfig environmentConfig, bool dryRun, CancellationToken ct = default); + + Task> ListQueues(CancellationToken ct = default); + + Task> UpsertQueueConfig(QueueConfig queueConfig, bool dryRun, CancellationToken ct = default); + + Task DeleteQueue(string queueName, bool dryRun, CancellationToken ct = default); +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Matchmaker/Service/MatchmakerDeploymentService.cs b/Unity.Services.Cli/Unity.Services.Cli.Matchmaker/Service/MatchmakerDeploymentService.cs new file mode 100644 index 0000000..ae5145c --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Matchmaker/Service/MatchmakerDeploymentService.cs @@ -0,0 +1,153 @@ +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.GameServerHosting.Services; +using Unity.Services.Matchmaker.Authoring.Core.ConfigApi; +using Unity.Services.Matchmaker.Authoring.Core.Deploy; +using Unity.Services.Matchmaker.Authoring.Core.Model; + +namespace Unity.Services.Cli.Matchmaker.Service; + +class MatchmakerDeploymentService : IDeploymentService +{ + readonly IMatchmakerDeployHandler m_DeploymentHandler; + readonly IConfigApiClient m_Client; + readonly IGameServerHostingConfigLoader m_GshConfigLoader; + + public MatchmakerDeploymentService( + IConfigApiClient client, + IMatchmakerDeployHandler deploymentHandler, + IGameServerHostingConfigLoader gshConfigLoader) + { + m_Client = client; + m_DeploymentHandler = deploymentHandler; + m_GshConfigLoader = gshConfigLoader; + } + + public string ServiceType => "Matchmaker"; + public string ServiceName => "matchmaker"; + + public IReadOnlyList FileExtensions + { + get => new[] { ".mme", ".mmq" }; + } + + public async Task Deploy( + DeployInput deployInput, + IReadOnlyList filePaths, + string projectId, + string environmentId, + StatusContext? loadingContext, + CancellationToken cancellationToken) + { + await m_Client.Initialize(projectId, environmentId, cancellationToken); + + loadingContext?.Status($"Deploying {ServiceType} files..."); + + var availableMultiplayConfig = await GetAvailableMultiplayResources(filePaths, deployInput, cancellationToken); + + var res = await m_DeploymentHandler.DeployAsync( + filePaths, + availableMultiplayConfig, + deployInput.Reconcile, + deployInput.DryRun, + cancellationToken); + + if (!string.IsNullOrEmpty(res.AbortMessage)) + throw new MatchmakerException(res.AbortMessage); + + return new DeploymentResult( + res.Updated, + res.Deleted, + res.Created, + res.Authored, + res.Failed, + deployInput.DryRun + ); + } + + Task GetAvailableMultiplayResources( + IReadOnlyList filePaths, + DeployInput deployInput, + CancellationToken ct) + { + var remoteMultiplayResources = m_Client.GetRemoteMultiplayResources(); + return Task.FromResult(remoteMultiplayResources); + + /* GameServerHosting deployment is not yet implemented in CLI so no point including GSH resources when doing a dry-run since they'll have to be deployed first with something else + var gshFilePaths = filePaths.Where(p => p.EndsWith(GameServerHostingConfigLoader.k_Extension)).ToList(); + var gshMultiplayResources = new Multiplay.Authoring.Core.Assets.MultiplayConfig(); + if (gshFilePaths.Any()) + { + gshMultiplayResources = await m_GshConfigLoader.LoadAndValidateAsync(gshFilePaths, ct); + } + var localMultiplayResources = new MultiplayResources() + { + Fleets = gshMultiplayResources.Fleets.Select( + f => new MultiplayResources.Fleet() + { + Name = f.Key.Name, + BuildConfigs = f.Value.BuildConfigurations.Select( + bc => new MultiplayResources.Fleet.BuildConfig() + { + Name = bc.Name, + }) + .ToList(), + QosRegions = f.Value.Regions.Select( + qr => new MultiplayResources.Fleet.QosRegion() + { + Name = qr.Key, + }) + .ToList() + }) + .ToList() + }; + + var availableMultiplayConfig = remoteMultiplayResources; + + if (deployInput.DryRun) // If not dry-run, remote is what we get since GSH is deployed before Matchmaker + { + if (deployInput.Services.Contains("GameServerHosting")) + { + if (deployInput.Reconcile) + { + availableMultiplayConfig = localMultiplayResources; + } + else // Merge local and remote multiplay resources + { + foreach (var fleet in localMultiplayResources.Fleets) + { + var existingFleet = availableMultiplayConfig.Fleets.FirstOrDefault(f => f.Name == fleet.Name); + if (existingFleet.Name != null) + { + availableMultiplayConfig.Fleets.Add(fleet); + } + else + { + // Merge QosRegions + foreach (var qosRegion in fleet.QosRegions) + { + if (existingFleet.QosRegions.All(q => q.Name != qosRegion.Name)) + { + existingFleet.QosRegions.Add(qosRegion); + } + } + + // Merge BuildConfigs + foreach (var buildConfig in fleet.BuildConfigs) + { + if (existingFleet.BuildConfigs.All(b => b.Name != buildConfig.Name)) + { + existingFleet.BuildConfigs.Add(buildConfig); + } + } + } + } + } + } + } + return availableMultiplayConfig; + }*/ + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Matchmaker/Service/MatchmakerException.cs b/Unity.Services.Cli/Unity.Services.Cli.Matchmaker/Service/MatchmakerException.cs new file mode 100644 index 0000000..d3bf494 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Matchmaker/Service/MatchmakerException.cs @@ -0,0 +1,25 @@ +using System.Runtime.Serialization; +using Unity.Services.Cli.Common.Exceptions; + +namespace Unity.Services.Cli.Matchmaker.Service; + +[Serializable] +public class MatchmakerException : CliException +{ + protected MatchmakerException(SerializationInfo info, StreamingContext context) : base(Common.Exceptions.ExitCode.HandledError) { } + + public MatchmakerException(int exitCode = Common.Exceptions.ExitCode.HandledError) + : base(exitCode) { } + + /// + /// constructor. + /// + /// A message with instructions to guide user how to fix the operation. + /// Exit code when this exception triggered. Default value is HandledError. + public MatchmakerException(string message, int exitCode = Common.Exceptions.ExitCode.HandledError) + : base(message, exitCode) { } + + public MatchmakerException( + string message, Exception innerException, int exitCode = Common.Exceptions.ExitCode.HandledError) + : base(message, innerException, exitCode) { } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Matchmaker/Service/MatchmakerFetchService.cs b/Unity.Services.Cli/Unity.Services.Cli.Matchmaker/Service/MatchmakerFetchService.cs new file mode 100644 index 0000000..2eadf67 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Matchmaker/Service/MatchmakerFetchService.cs @@ -0,0 +1,51 @@ +using Spectre.Console; +using Unity.Services.Cli.Authoring.Input; +using Unity.Services.Cli.Authoring.Service; +using Unity.Services.Matchmaker.Authoring.Core.ConfigApi; +using Unity.Services.Matchmaker.Authoring.Core.Fetch; +using FetchResult = Unity.Services.Cli.Authoring.Model.FetchResult; + +namespace Unity.Services.Cli.Matchmaker.Service; + +class MatchmakerFetchService : IFetchService +{ + readonly IMatchmakerFetchHandler m_FetchHandler; + readonly IConfigApiClient m_Client; + + public MatchmakerFetchService(IConfigApiClient client, IMatchmakerFetchHandler fetchHandler) + { + m_Client = client; + m_FetchHandler = fetchHandler; + } + + public string ServiceType => "Matchmaker"; + public string ServiceName => "matchmaker"; + public IReadOnlyList FileExtensions + { + get => new[] { ".mme", ".mmq" }; + } + + public async Task FetchAsync( + FetchInput input, + IReadOnlyList filePaths, + string projectId, + string environmentId, + StatusContext? loadingContext, + CancellationToken cancellationToken) + { + await m_Client.Initialize(projectId, environmentId, cancellationToken); + loadingContext?.Status($"Fetching {ServiceType} files..."); + if (File.Exists(input.Path)) + { + throw new MatchmakerException("The provided path is not a directory."); + } + + var res = await m_FetchHandler.FetchAsync(input.Path, filePaths, input.Reconcile, input.DryRun, cancellationToken); + + if (!string.IsNullOrEmpty(res.AbortMessage)) + throw new MatchmakerException(res.AbortMessage); + + return new FetchResult(res.Updated, res.Deleted, res.Created, res.Authored, res.Failed, input.DryRun); + } +} + diff --git a/Unity.Services.Cli/Unity.Services.Cli.Matchmaker/Service/MatchmakerService.cs b/Unity.Services.Cli/Unity.Services.Cli.Matchmaker/Service/MatchmakerService.cs new file mode 100644 index 0000000..2e79592 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Matchmaker/Service/MatchmakerService.cs @@ -0,0 +1,167 @@ +using System.Net; +using Unity.Services.Gateway.MatchmakerAdminApiV3.Generated.Api; +using Unity.Services.Gateway.MatchmakerAdminApiV3.Generated.Model; +using Newtonsoft.Json; +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.MatchmakerAdminApiV3.Generated.Client; +using Unity.Services.Matchmaker.Authoring.Core.Model; +using EnvironmentConfig = Unity.Services.Gateway.MatchmakerAdminApiV3.Generated.Model.EnvironmentConfig; +using QueueConfig = Unity.Services.Gateway.MatchmakerAdminApiV3.Generated.Model.QueueConfig; + + +namespace Unity.Services.Cli.Matchmaker.Service; + +public class MatchmakerService : IMatchmakerService +{ + readonly IMatchmakerAdminApi m_MatchmakerAdminApi; + readonly IServiceAccountAuthenticationService m_AuthenticationService; + readonly IConfigurationValidator m_ConfigValidator; + string m_ProjectId = String.Empty; + string m_EnvironmentId = String.Empty; + + public MatchmakerService( + IMatchmakerAdminApi matchmakerAdminApi, + IServiceAccountAuthenticationService authenticationService, + IConfigurationValidator validator) + { + m_MatchmakerAdminApi = matchmakerAdminApi; + m_AuthenticationService = authenticationService; + m_ConfigValidator = validator; + } + + public async Task Initialize(string projectId, string environmentId, CancellationToken ct = default) + { + m_ConfigValidator.ThrowExceptionIfConfigInvalid(Keys.ConfigKeys.ProjectId, projectId); + m_ConfigValidator.ThrowExceptionIfConfigInvalid(Keys.ConfigKeys.EnvironmentId, environmentId); + var token = await m_AuthenticationService.GetAccessTokenAsync(ct); + m_MatchmakerAdminApi.Configuration.DefaultHeaders.SetAccessTokenHeader(token); + m_ProjectId = projectId; + m_EnvironmentId = environmentId; + + return string.Empty; + } + + public async Task<(bool, EnvironmentConfig)> GetEnvironmentConfig(CancellationToken ct = default) + { + ApiResponse? response; + try + { + response = await m_MatchmakerAdminApi.GetEnvironmentConfigWithHttpInfoAsync( + m_ProjectId, + m_EnvironmentId, + cancellationToken: ct); + } + catch (ApiException e) + { + if (e.ErrorCode == (int)HttpStatusCode.NotFound) + return (false, new EnvironmentConfig()); + throw; + } + + return (true, response.Data); + } + + public async Task> UpsertEnvironmentConfig( + EnvironmentConfig environmentConfig, + bool dryRun, + CancellationToken ct = default) + { + try + { + await m_MatchmakerAdminApi.UpdateEnvironmentConfigWithHttpInfoAsync( + m_ProjectId, + m_EnvironmentId, + dryRun, + environmentConfig, + cancellationToken: ct); + } + catch (ApiException e) + { + if (e.ErrorCode == (int)HttpStatusCode.BadRequest) + { + return JsonConvert.DeserializeObject((string)e.ErrorContent) + ?.Details.Select( + x => new ErrorResponse() + { + ResultCode = x.ResultCode, + Message = x.Message + }) + .ToList() + ?? new List(); + } + + throw; + } + + return new List(); + } + + public async Task> ListQueues(CancellationToken ct = default) + { + ApiResponse>? response; + try + { + response = await m_MatchmakerAdminApi.ListQueuesWithHttpInfoAsync( + m_ProjectId, + m_EnvironmentId, + cancellationToken: ct); + } + catch (ApiException e) + { + if (e.ErrorCode == (int)HttpStatusCode.NotFound) + return new List(); + throw; + } + + return response.Data; + } + + public async Task> UpsertQueueConfig( + QueueConfig queueConfig, + bool dryRun, + CancellationToken ct = default) + { + try + { + await m_MatchmakerAdminApi.UpsertQueueConfigWithHttpInfoAsync( + m_ProjectId, + m_EnvironmentId, + queueConfig.Name, + dryRun, + queueConfig, + cancellationToken: ct); + } + catch (ApiException e) + { + if (e.ErrorCode == (int)HttpStatusCode.BadRequest) + { + return JsonConvert.DeserializeObject((string)e.ErrorContent) + ?.Details.Select( + x => new ErrorResponse() + { + ResultCode = x.ResultCode, + Message = x.Message + }) + .ToList() + ?? new List(); + } + + throw; + } + + return new List(); + } + + public async Task DeleteQueue(string queueName, bool dryRun, CancellationToken ct = default) + { + await m_MatchmakerAdminApi.DeleteQueueAsync( + m_ProjectId, + m_EnvironmentId, + queueName, + dryRun, + cancellationToken: ct); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Matchmaker/Service/QueueConfigTemplate.cs b/Unity.Services.Cli/Unity.Services.Cli.Matchmaker/Service/QueueConfigTemplate.cs new file mode 100644 index 0000000..f2176e8 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Matchmaker/Service/QueueConfigTemplate.cs @@ -0,0 +1,87 @@ +using Newtonsoft.Json; +using Unity.Services.Cli.Authoring.Templates; +using Unity.Services.Cli.Matchmaker.Parser; +using Unity.Services.Matchmaker.Authoring.Core.Model; +using Unity.Services.Matchmaker.Authoring.Core.Parser; +using Range = Unity.Services.Matchmaker.Authoring.Core.Model.Range; + +namespace Unity.Services.Cli.Matchmaker.Service; + +class QueueConfigTemplate : QueueConfig, IFileTemplate +{ + public QueueConfigTemplate() + { + Name = new QueueName("default-queue"); + Enabled = true; + MaxPlayersPerTicket = 2; + DefaultPool = new BasePoolConfig + { + Name = new PoolName("default-pool"), + Enabled = true, + TimeoutSeconds = 90, + MatchLogic = new MatchLogicRulesConfig + { + Name = "Default Pool Rules", + MatchDefinition = new RuleBasedMatchDefinition() + { + matchRules = new List() + { + new Rule() + { + name = "skill-diff", + type = RuleType.Difference, + reference = new JsonObject("500"), + source = "Players.ExternalData.CloudSave.Skill" + }, + new Rule() + { + name = "QoS", + type = RuleType.LessThanEqual, + reference = new JsonObject("100"), + source = "Players.QosResults.Latency", + relaxations = new List() + { + new RuleRelaxation() + { + ageType = AgeType.Oldest, + atSeconds = 30, + type = RuleRelaxationType.ReferenceControlReplace, + value = new JsonObject("200") + } + } + } + }, + teams = new List() + { + new RuleBasedTeamDefinition() + { + name = "Team", + playerCount = new Range() + { + min = 1, + max = 2 + }, + teamCount = new Range() + { + min = 2, + max = 2 + } + } + } + } + }, + MatchHosting = new MultiplayConfig + { + Type = IMatchHostingConfig.MatchHostingType.Multiplay, + FleetName = "my fleet", + BuildConfigurationName = "my build configuration", + DefaultQoSRegionName = "North America" + } + }; + } + + [JsonIgnore] public string Extension => IMatchmakerConfigParser.QueueConfigExtension; + + [JsonIgnore] + public string FileBodyText => JsonConvert.SerializeObject(new QueueConfigTemplate(), MatchmakerConfigParser.JsonSerializerSettings); +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.Matchmaker/Unity.Services.Cli.Matchmaker.csproj b/Unity.Services.Cli/Unity.Services.Cli.Matchmaker/Unity.Services.Cli.Matchmaker.csproj new file mode 100644 index 0000000..a0c7531 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.Matchmaker/Unity.Services.Cli.Matchmaker.csproj @@ -0,0 +1,29 @@ + + + net8.0 + 10 + enable + enable + true + + + + <_Parameter1>$(AssemblyName).UnitTest + + + <_Parameter1>DynamicProxyGenAssembly2 + + + + + + + + + + + + + $(DefineConstants);$(ExtraDefineConstants) + + \ No newline at end of file diff --git a/Unity.Services.Cli/Unity.Services.Cli.sln b/Unity.Services.Cli/Unity.Services.Cli.sln index 0c99f09..cfd0513 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.sln +++ b/Unity.Services.Cli/Unity.Services.Cli.sln @@ -84,8 +84,11 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Unity.Services.Cli.Triggers EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Unity.Services.Cli.CloudContentDelivery.UnitTest", "Unity.Services.Cli.CloudContentDelivery.UnitTest\Unity.Services.Cli.CloudContentDelivery.UnitTest.csproj", "{025F0D8D-97A2-4698-87FA-7FB40FF1E515}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Unity.Services.Cli.CloudSave", "Unity.Services.Cli.CloudSave\Unity.Services.Cli.CloudSave.csproj", "{216E635D-2955-42C4-80B8-72878ECB8672}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Unity.Services.CloudSave.Authoring.Core", "Unity.Services.CloudSave.Authoring.Core\Unity.Services.CloudSave.Authoring.Core.csproj", "{D2B2D763-4848-4450-9EF8-2CA9348595F5}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Unity.Services.Cli.CloudSave.UnitTest", "Unity.Services.Cli.CloudSave.UnitTest\Unity.Services.Cli.CloudSave.UnitTest.csproj", "{6E694B38-E1C8-4F86-8240-3F7E85081BFA}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Unity.Services.Cli.Scheduler", "Unity.Services.Cli.Scheduler\Unity.Services.Cli.Scheduler.csproj", "{7BA75DA5-852E-4B13-AA6A-C79B7E268418}" EndProject @@ -95,6 +98,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Unity.Services.Cli.Schedule EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Latest", "Latest", "{210148BF-92DB-4EC2-9C1D-DB10E8ED7BB4}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Unity.Services.Cli.Matchmaker", "Unity.Services.Cli.Matchmaker\Unity.Services.Cli.Matchmaker.csproj", "{C450D958-248C-4FB5-A3C4-28CF7F2CC05C}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Badges", "Badges", "{2BA93646-8046-4B05-BB54-39D3F8975DFA}" ProjectSection(SolutionItems) = preProject @@ -135,6 +139,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Entries", "Entries", "{43EC EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "CCD", "CCD", "{44AEB9D3-CDE4-4C0C-AE4E-07605839E89D}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Unity.Services.Cli.Matchmaker.UnitTest", "Unity.Services.Cli.Matchmaker.UnitTest\Unity.Services.Cli.Matchmaker.UnitTest.csproj", "{CECE2AFF-200D-4EDC-9FD5-33C58A256E4A}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -292,6 +297,18 @@ Global {025F0D8D-97A2-4698-87FA-7FB40FF1E515}.Debug|Any CPU.Build.0 = Debug|Any CPU {025F0D8D-97A2-4698-87FA-7FB40FF1E515}.Release|Any CPU.ActiveCfg = Release|Any CPU {025F0D8D-97A2-4698-87FA-7FB40FF1E515}.Release|Any CPU.Build.0 = Release|Any CPU + {216E635D-2955-42C4-80B8-72878ECB8672}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {216E635D-2955-42C4-80B8-72878ECB8672}.Debug|Any CPU.Build.0 = Debug|Any CPU + {216E635D-2955-42C4-80B8-72878ECB8672}.Release|Any CPU.ActiveCfg = Release|Any CPU + {216E635D-2955-42C4-80B8-72878ECB8672}.Release|Any CPU.Build.0 = Release|Any CPU + {D2B2D763-4848-4450-9EF8-2CA9348595F5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D2B2D763-4848-4450-9EF8-2CA9348595F5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D2B2D763-4848-4450-9EF8-2CA9348595F5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D2B2D763-4848-4450-9EF8-2CA9348595F5}.Release|Any CPU.Build.0 = Release|Any CPU + {6E694B38-E1C8-4F86-8240-3F7E85081BFA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6E694B38-E1C8-4F86-8240-3F7E85081BFA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6E694B38-E1C8-4F86-8240-3F7E85081BFA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6E694B38-E1C8-4F86-8240-3F7E85081BFA}.Release|Any CPU.Build.0 = Release|Any CPU {7BA75DA5-852E-4B13-AA6A-C79B7E268418}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {7BA75DA5-852E-4B13-AA6A-C79B7E268418}.Debug|Any CPU.Build.0 = Debug|Any CPU {7BA75DA5-852E-4B13-AA6A-C79B7E268418}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -304,10 +321,18 @@ Global {D511207A-820C-4AA9-AF46-D76F1027F105}.Debug|Any CPU.Build.0 = Debug|Any CPU {D511207A-820C-4AA9-AF46-D76F1027F105}.Release|Any CPU.ActiveCfg = Release|Any CPU {D511207A-820C-4AA9-AF46-D76F1027F105}.Release|Any CPU.Build.0 = Release|Any CPU + {C450D958-248C-4FB5-A3C4-28CF7F2CC05C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C450D958-248C-4FB5-A3C4-28CF7F2CC05C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C450D958-248C-4FB5-A3C4-28CF7F2CC05C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C450D958-248C-4FB5-A3C4-28CF7F2CC05C}.Release|Any CPU.Build.0 = Release|Any CPU {77576293-7E51-4A9C-8DDA-F2D75C8C59ED}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {77576293-7E51-4A9C-8DDA-F2D75C8C59ED}.Debug|Any CPU.Build.0 = Debug|Any CPU {77576293-7E51-4A9C-8DDA-F2D75C8C59ED}.Release|Any CPU.ActiveCfg = Release|Any CPU {77576293-7E51-4A9C-8DDA-F2D75C8C59ED}.Release|Any CPU.Build.0 = Release|Any CPU + {CECE2AFF-200D-4EDC-9FD5-33C58A256E4A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CECE2AFF-200D-4EDC-9FD5-33C58A256E4A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CECE2AFF-200D-4EDC-9FD5-33C58A256E4A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CECE2AFF-200D-4EDC-9FD5-33C58A256E4A}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {44AEB9D3-CDE4-4C0C-AE4E-07605839E89D} = {210148BF-92DB-4EC2-9C1D-DB10E8ED7BB4} diff --git a/Unity.Services.Cli/Unity.Services.Cli.sln.DotSettings b/Unity.Services.Cli/Unity.Services.Cli.sln.DotSettings index 4c157dc..f717aa9 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.sln.DotSettings +++ b/Unity.Services.Cli/Unity.Services.Cli.sln.DotSettings @@ -13,7 +13,9 @@ CHOP_IF_LONG DT <Policy Inspect="True" Prefix="k_" Suffix="" Style="AaBb" /> + <Policy><Descriptor Staticness="Static" AccessRightKinds="Private" Description="Static readonly fields (private)"><ElementKinds><Kind Name="READONLY_FIELD" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="k_" Suffix="" Style="AaBb" /></Policy> True True True - True \ No newline at end of file + True + True \ No newline at end of file diff --git a/Unity.Services.Cli/Unity.Services.Cli/Program.cs b/Unity.Services.Cli/Unity.Services.Cli/Program.cs index 981120e..54395db 100644 --- a/Unity.Services.Cli/Unity.Services.Cli/Program.cs +++ b/Unity.Services.Cli/Unity.Services.Cli/Program.cs @@ -26,6 +26,7 @@ using Unity.Services.Cli.Common.Telemetry.AnalyticEvent.AnalyticEventFactory; using Unity.Services.Cli.Authoring; using Unity.Services.Cli.GameServerHosting; +using Unity.Services.Cli.Matchmaker; #if FEATURE_ECONOMY using Unity.Services.Cli.Economy; #endif @@ -38,6 +39,7 @@ using Unity.Services.Cli.Player; using Unity.Services.Cli.Access; using Unity.Services.Cli.Scheduler; +using Unity.Services.Cli.CloudSave; using Unity.Services.Cli.CloudContentDelivery; @@ -90,10 +92,12 @@ public static async Task InternalMain(string[] args, Logger logger) #if FEATURE_TRIGGERS host.ConfigureServices(TriggersModule.RegisterServices); #endif + host.ConfigureServices(CloudSaveModule.RegisterServices); host.ConfigureServices(LeaderboardsModule.RegisterServices); host.ConfigureServices(PlayerModule.RegisterServices); host.ConfigureServices(CloudContentDeliveryModule.RegisterServices); + host.ConfigureServices(MatchmakerModule.RegisterServices); host.ConfigureServices(serviceCollection => serviceCollection .AddSingleton(systemEnvironmentProvider)); @@ -163,12 +167,13 @@ public static async Task InternalMain(string[] args, Logger logger) #if FEATURE_TRIGGERS .AddModule(new TriggersModule()) #endif + .AddModule(new CloudSaveModule()) .AddModule(new LobbyModule()) .AddModule(new GameServerHostingModule()) .AddModule(new PlayerModule()) .AddModule(new RemoteConfigModule()) - .AddModule(new CloudContentDeliveryModule()) + .AddModule(new MatchmakerModule()) .Build(); return await parser diff --git a/Unity.Services.Cli/Unity.Services.Cli/Unity.Services.Cli.csproj b/Unity.Services.Cli/Unity.Services.Cli/Unity.Services.Cli.csproj index 6835e6e..695b104 100644 --- a/Unity.Services.Cli/Unity.Services.Cli/Unity.Services.Cli.csproj +++ b/Unity.Services.Cli/Unity.Services.Cli/Unity.Services.Cli.csproj @@ -4,7 +4,7 @@ net8.0 10 ugs - 1.4.0 + 1.5.0 true true @@ -22,6 +22,7 @@ + @@ -36,6 +37,7 @@ + diff --git a/Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/Batching/Batching.cs b/Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/Batching/Batching.cs new file mode 100644 index 0000000..cf7554c --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/Batching/Batching.cs @@ -0,0 +1,259 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Unity.Services.CloudSave.Authoring.Core.Batching +{ + /// An utility class for executing delegates in batches with a time interval between them + /// Currently only supports async delegates (with or without return values) + public static class Batching + { + const int k_BatchSize = 10; + const double k_SecondsDelay = 1; + + const string k_BatchingExceptionMessage = + "One or more exceptions were thrown during the batching execution. See inner exceptions."; + + /// + /// Asynchronously execute a collection of delegates in batches with delay between them + /// + /// IEnumerable of the delegates you want to run in batches + /// Callback that will be invoked when a delegate has finished executing + /// + /// Size of the batches + /// Delay in seconds between batches + /// Exception thrown when a delegate throws an exception + /// You need to handle the AggregateException's innerExceptions (that's where you'll get + /// the exceptions related to the individual batch items executed) + public static async Task ExecuteInBatchesAsync( + IEnumerable> delegates, + CancellationToken cancellationToken, + int batchSize = k_BatchSize, + double secondsDelay = k_SecondsDelay) + { + var newDelegates = new List>>(); + + foreach (var del in delegates) + { + newDelegates.Add( + async () => + { + await Task.Run(del, cancellationToken); + return 0; + }); + + if (cancellationToken.IsCancellationRequested) + { + return; + } + } + + await ExecuteInBatchesAsync( + newDelegates, + cancellationToken, + batchSize, + secondsDelay); + } + + /// + /// Asynchronously execute a collection of delegates in batches with delay between them + /// + /// IEnumerable of the delegates you want to run in batches + /// Callback that will be invoked when a delegate has finished executing + /// + /// Size of the batches + /// Delay in seconds between batches + /// The return value type of your delegates + /// A collection of results + /// Exception thrown when a delegate throws an exception + /// You need to handle the AggregateException's innerExceptions (that's where you'll get + /// the exceptions related to the individual batch items executed) + public static async Task> ExecuteInBatchesAsync( + IReadOnlyList>> delegates, + CancellationToken cancellationToken, + int batchSize = k_BatchSize, + double secondsDelay = k_SecondsDelay) + { + var exceptions = new ConcurrentQueue(); + var batchesResult = new ConcurrentBag(); + var chunks = delegates.Chunk(batchSize).ToList(); + + for (int i = 0; i < chunks.Count; i++) + { + try + { + var batchResult = await ExecuteBatchAsync(chunks[i], cancellationToken); + foreach (var result in batchResult) + { + batchesResult.Add(result); + } + } + catch (AggregateException e) + { + foreach (var innerException in e.InnerExceptions) + { + exceptions.Enqueue(innerException); + } + } + + if (i + 1 != chunks.Count) + { + await Task.Delay(TimeSpan.FromSeconds(secondsDelay), cancellationToken); + } + + if (cancellationToken.IsCancellationRequested) + { + break; + } + } + + if (!exceptions.IsEmpty) + { + throw new AggregateException(k_BatchingExceptionMessage, exceptions.ToList()); + } + + return batchesResult.ToList(); + } + + public static async Task ExecuteInBatchesAsync( + IEnumerable tasks, + CancellationToken cancellationToken, + int batchSize = k_BatchSize, + double secondsDelay = k_SecondsDelay) + { + var exceptions = new List(); + var iterator = tasks.GetEnumerator(); + + while (true) + { + var chunk = new List(); + for (int i = 0; i < batchSize; ++i) + { + if (!iterator.MoveNext()) + break; + chunk.Add(iterator.Current); + } + + if (chunk.Count == 0) + break; + + var innerExceptions = await ExecuteBatchAsync(chunk); + exceptions.AddRange(innerExceptions); + + if (cancellationToken.IsCancellationRequested) + break; + + await Task.Delay(TimeSpan.FromSeconds(secondsDelay), cancellationToken); + + if (cancellationToken.IsCancellationRequested) + break; + } + + iterator.Dispose(); + + if (exceptions.Count != 0) + { + throw new AggregateException(k_BatchingExceptionMessage, exceptions.ToList()); + } + } + + static async Task> ExecuteBatchAsync( + IEnumerable>> delegates, + CancellationToken cancellationToken) + { + var exceptions = new ConcurrentQueue(); + var batchesResult = new ConcurrentBag(); + var tasks = new ConcurrentBag(); + + Parallel.ForEach( + delegates, + del => + { + var task = Task.Run( + async () => + { + try + { + var result = await Task.Run(del, cancellationToken); + batchesResult.Add(result); + } + catch (Exception e) + { + exceptions.Enqueue(e); + } + }, + cancellationToken); + + tasks.Add(task); + }); + + await Task.WhenAll(tasks); + + if (!exceptions.IsEmpty) + { + throw new AggregateException(k_BatchingExceptionMessage, exceptions.ToList()); + } + + return batchesResult.ToList(); + } + + static async Task> ExecuteBatchAsync(IEnumerable insideTasks) + { + var tasks = new ConcurrentBag(); + var exceptions = new ConcurrentQueue(); + + Parallel.ForEach( + insideTasks, + async del => + { + tasks.Add(del); + try + { + await del; + } + catch (Exception e) + { + exceptions.Enqueue(e); + } + }); + + await Task.WhenAll(tasks); + + return exceptions.ToList(); + } + + // Copy/pasted utility code from the Enumerable.Chunk method available in dotnet 5 + static IEnumerable ChunkIterator(IEnumerable source, int size) + { + using IEnumerator e = source.GetEnumerator(); + while (e.MoveNext()) + { + TSource[] chunk = new TSource[size]; + chunk[0] = e.Current; + + int i = 1; + for (; i < chunk.Length && e.MoveNext(); i++) + { + chunk[i] = e.Current; + } + + if (i == chunk.Length) + { + yield return chunk; + } + else + { + Array.Resize(ref chunk, i); + yield return chunk; + yield break; + } + } + } + + static IEnumerable Chunk(this IEnumerable source, int size) + => ChunkIterator(source, size); + } +} diff --git a/Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/Deploy/CloudSaveDeploymentHandler.cs b/Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/Deploy/CloudSaveDeploymentHandler.cs new file mode 100644 index 0000000..5698359 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/Deploy/CloudSaveDeploymentHandler.cs @@ -0,0 +1,119 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Unity.Services.CloudSave.Authoring.Core.Model; +using Unity.Services.CloudSave.Authoring.Core.Service; +using Unity.Services.CloudSave.Authoring.Core.Validations; + +namespace Unity.Services.CloudSave.Authoring.Core.Deploy +{ + public class CloudSaveDeploymentHandler : CloudSaveFetchDeployBase, ICloudSaveDeploymentHandler + { + public CloudSaveDeploymentHandler(ICloudSaveClient client) + : base(client) { } + + public async Task DeployAsync( + IReadOnlyList localResources, + bool dryRun = false, + bool reconcile = false, + CancellationToken token = default) + { + var res = new DeployResult(); + + var filteredLocalResources = DuplicateResourceValidation.FilterDuplicateResources( + localResources, out var duplicateGroups); + + UpdateDuplicateResourceStatus(duplicateGroups); + + var remoteResources = await GetRemoteItems(cancellationToken: token); + + SetupMaps(filteredLocalResources, remoteResources); + + var toCreate = filteredLocalResources + .Where(DoesNotExistRemotely) + .ToList(); + + var toUpdate = filteredLocalResources + .Where(ExistsRemotely) + .ToList(); + + var toDelete = new List(); + if (reconcile) + { + toDelete = remoteResources + .Where(DoesNotExistLocally) + .ToList(); + } + + res.Deployed = localResources.Concat(toDelete).ToList(); + + if (dryRun) + { + UpdateDryRunResult(toUpdate, toDelete, toCreate); + return res; + } + + filteredLocalResources.ForEach(l => l.Progress = 50); + + var createTasks = GetTasks(toCreate, Client.Create, Constants.Created, token); + var updateTasks = GetTasks(toUpdate, Client.Update, Constants.Updated, token); + var deleteTasks = reconcile + ? GetTasks(toDelete, Client.Delete, Constants.Deleted, token) + : new List(); + + var allTasks = createTasks.Concat(updateTasks).Concat(deleteTasks); + + await Batching.Batching.ExecuteInBatchesAsync(allTasks, token); + + return res; + } + + static IEnumerable GetTasks( + List resources, + Func func, + string taskAction, + CancellationToken token) + { + return resources.Select(i => DeployResource(func, i, taskAction, token)); + } + + static void UpdateDryRunResult(List toUpdate, List toDelete, List toCreate) + { + foreach (var i in toUpdate) + { + i.Status = Statuses.GetDeployed(Constants.Updated); + } + + foreach (var i in toDelete) + { + i.Status = Statuses.GetDeployed(Constants.Deleted); + } + + foreach (var i in toCreate) + { + i.Status = Statuses.GetDeployed(Constants.Created); + } + } + + static async Task DeployResource( + Func task, + IResourceDeploymentItem resource, + string taskAction, + CancellationToken token) + { + try + { + resource.Status = Statuses.GetDeploying(); + await task(resource.Resource, token); + resource.Status = Statuses.GetDeployed(taskAction); + resource.Progress = 100f; + } + catch (Exception e) + { + resource.Status = Statuses.GetFailedToDeploy(e.Message); + } + } + } +} diff --git a/Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/Deploy/CloudSaveFetchDeployBase.cs b/Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/Deploy/CloudSaveFetchDeployBase.cs new file mode 100644 index 0000000..270d185 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/Deploy/CloudSaveFetchDeployBase.cs @@ -0,0 +1,86 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Unity.Services.CloudSave.Authoring.Core.Model; +using Unity.Services.CloudSave.Authoring.Core.Service; +using Unity.Services.CloudSave.Authoring.Core.Validations; + +namespace Unity.Services.CloudSave.Authoring.Core.Deploy +{ + public class CloudSaveFetchDeployBase + { + IReadOnlyDictionary m_LocalMap; + IReadOnlyDictionary m_RemoteMap; + protected ICloudSaveClient Client { get; } + + public CloudSaveFetchDeployBase(ICloudSaveClient client) + { + Client = client; + } + + protected void SetupMaps(List filteredLocalResources, IReadOnlyList remoteResources) + { + //TODO: Verify the right nomenclature for your ID here, or use `Name` + m_LocalMap = filteredLocalResources.ToDictionary(l => l.Resource.Id, l => l); + m_RemoteMap = remoteResources.ToDictionary(l => l.Resource.Id, l => l); + } + + protected async Task> GetRemoteItems( + string rootDirectory = null, + CancellationToken cancellationToken = default) + { + var remoteResources = await Client.List(cancellationToken); + var remoteItems = remoteResources + .Select( + resource => + { + var path = rootDirectory != null + ? Path.Combine(rootDirectory, resource.Id + Constants.SimpleFileExtension) + : "Remote"; + var deploymentItem = new SimpleResourceDeploymentItem(resource.Id, path) + { + Resource = resource + }; + return deploymentItem; + }) + .ToList(); + return remoteItems; + } + + protected bool ExistsRemotely(IResourceDeploymentItem resource) + { + return m_RemoteMap.ContainsKey(resource.Resource.Id); + } + + protected bool DoesNotExistRemotely(IResourceDeploymentItem resource) + { + return !m_RemoteMap.ContainsKey(resource.Resource.Id); + } + + protected bool DoesNotExistLocally(IResourceDeploymentItem resource) + { + return !m_LocalMap.ContainsKey(resource.Resource.Id); + } + + protected IResourceDeploymentItem GetRemoteResourceItem(string id) + { + return m_RemoteMap[id]; + } + + protected static void UpdateDuplicateResourceStatus( + IReadOnlyList> duplicateGroups) + { + foreach (var group in duplicateGroups) + { + foreach (var resourceItem in group) + { + var (message, shortMessage) = DuplicateResourceValidation.GetDuplicateResourceErrorMessages(resourceItem, group.ToList()); + resourceItem.Status = Statuses.GetFailedToFetch(shortMessage); + } + } + } + + } +} diff --git a/Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/Deploy/DeployResult.cs b/Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/Deploy/DeployResult.cs new file mode 100644 index 0000000..9917780 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/Deploy/DeployResult.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; +using Unity.Services.CloudSave.Authoring.Core.Model; + +namespace Unity.Services.CloudSave.Authoring.Core.Deploy +{ + public class DeployResult + { + public IReadOnlyList Deployed { get; set; } + } +} diff --git a/Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/Deploy/ICloudSaveDeploymentHandler.cs b/Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/Deploy/ICloudSaveDeploymentHandler.cs new file mode 100644 index 0000000..3d6f1fa --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/Deploy/ICloudSaveDeploymentHandler.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Unity.Services.CloudSave.Authoring.Core.Model; + +namespace Unity.Services.CloudSave.Authoring.Core.Deploy +{ + public interface ICloudSaveDeploymentHandler + { + Task DeployAsync( + IReadOnlyList localResources, + bool dryRun = false, + bool reconcile = false, + CancellationToken token = default); + } + +} diff --git a/Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/Fetch/CloudSaveFetchHandler.cs b/Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/Fetch/CloudSaveFetchHandler.cs new file mode 100644 index 0000000..fd4e9d9 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/Fetch/CloudSaveFetchHandler.cs @@ -0,0 +1,154 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Unity.Services.CloudSave.Authoring.Core.Deploy; +using Unity.Services.CloudSave.Authoring.Core.IO; +using Unity.Services.CloudSave.Authoring.Core.Model; +using Unity.Services.CloudSave.Authoring.Core.Service; +using Unity.Services.CloudSave.Authoring.Core.Validations; + +namespace Unity.Services.CloudSave.Authoring.Core.Fetch +{ + public class CloudSaveFetchHandler : CloudSaveFetchDeployBase, ICloudSaveFetchHandler + { + readonly ICloudSaveSimpleResourceLoader m_ResourceLoader; + + public CloudSaveFetchHandler( + ICloudSaveClient client, + ICloudSaveSimpleResourceLoader resourceLoader) + : base(client) + { + m_ResourceLoader = resourceLoader; + } + + public async Task FetchAsync( + string rootDirectory, + IReadOnlyList localResources, + bool dryRun = false, + bool reconcile = false, + CancellationToken token = default) + { + localResources.ToList().ForEach(l => l.Progress = 0f); + + var filteredLocalResources = DuplicateResourceValidation.FilterDuplicateResources( + localResources, out var duplicateGroups); + + UpdateDuplicateResourceStatus(duplicateGroups); + + var remoteResources = await GetRemoteItems(rootDirectory, token); + + SetupMaps(filteredLocalResources, remoteResources); + + var toUpdate = filteredLocalResources + .Where(ExistsRemotely) + .ToList(); + + var toDelete = filteredLocalResources + .Where(DoesNotExistRemotely) + .ToList(); + + var toCreate = new List(); + if (reconcile) + { + toCreate = remoteResources + .Where(DoesNotExistLocally) + .ToList(); + } + + var res = new FetchResult + { + Fetched = localResources.Concat(toCreate).ToList() + }; + + if (dryRun) + { + UpdateDryRunResult(toUpdate, toDelete, toCreate); + return res; + } + + filteredLocalResources.ForEach(l => l.Progress = 50); + + var updateTasks = CreateOrUpdateResources(toUpdate, token); + + var deleteTasks = DeleteResources(toDelete, token); + + var createTasks = new List<(IResourceDeploymentItem, Task)>(); + if (reconcile) + { + createTasks = CreateOrUpdateResources(toCreate, token); + } + + await WaitForTasks(updateTasks, Constants.Updated); + await WaitForTasks(deleteTasks, Constants.Deleted); + await WaitForTasks(createTasks, Constants.Created); + + return res; + } + + static void UpdateDryRunResult(List toUpdate, List toDelete, List toCreate) + { + foreach (var i in toUpdate) + { + i.Status = Statuses.GetFetched(Constants.Updated); + } + + foreach (var i in toDelete) + { + i.Status = Statuses.GetFetched(Constants.Deleted); + } + + foreach (var i in toCreate) + { + i.Status = Statuses.GetFetched(Constants.Created); + } + } + + List<(IResourceDeploymentItem, Task)> CreateOrUpdateResources(List toUpdate, CancellationToken token) + { + List<(IResourceDeploymentItem, Task)> updateTasks = new List<(IResourceDeploymentItem, Task)>(); + foreach (var item in toUpdate) + { + item.Resource = GetRemoteResourceItem(item.Resource.Id).Resource; + var task = m_ResourceLoader.CreateOrUpdateResource(item, token); + updateTasks.Add((item, task)); + } + + return updateTasks; + } + + List<(IResourceDeploymentItem, Task)> DeleteResources(List toDelete, CancellationToken token) + { + List<(IResourceDeploymentItem, Task)> deleteTasks = new List<(IResourceDeploymentItem, Task)>(); + foreach (var resource in toDelete) + { + var task = m_ResourceLoader.DeleteResource( + resource, + token); + deleteTasks.Add((resource, task)); + } + + return deleteTasks; + } + + static async Task WaitForTasks( + List<(IResourceDeploymentItem, Task)> tasks, + string taskAction) + { + foreach (var (resource, task) in tasks) + { + try + { + await task; + resource.Progress = 100f; + resource.Status = Statuses.GetFetched(taskAction); + } + catch (Exception e) + { + resource.Status = Statuses.GetFailedToFetch(e.Message); + } + } + } + } +} diff --git a/Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/Fetch/FetchResult.cs b/Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/Fetch/FetchResult.cs new file mode 100644 index 0000000..91f8175 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/Fetch/FetchResult.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; +using Unity.Services.CloudSave.Authoring.Core.Model; + +namespace Unity.Services.CloudSave.Authoring.Core.Fetch +{ + public class FetchResult + { + public IReadOnlyList Fetched { get; set; } + } +} diff --git a/Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/Fetch/ICloudSaveFetchHandler.cs b/Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/Fetch/ICloudSaveFetchHandler.cs new file mode 100644 index 0000000..087b59a --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/Fetch/ICloudSaveFetchHandler.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Unity.Services.CloudSave.Authoring.Core.Model; + +namespace Unity.Services.CloudSave.Authoring.Core.Fetch +{ + public interface ICloudSaveFetchHandler + { + public Task FetchAsync( + string rootDirectory, + IReadOnlyList localResources, + bool dryRun = false, + bool reconcile = false, + CancellationToken token = default); + } +} diff --git a/Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/IO/ICloudSaveSimpleResourceLoader.cs b/Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/IO/ICloudSaveSimpleResourceLoader.cs new file mode 100644 index 0000000..879b665 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/IO/ICloudSaveSimpleResourceLoader.cs @@ -0,0 +1,13 @@ +using System.Threading; +using System.Threading.Tasks; +using Unity.Services.CloudSave.Authoring.Core.Model; + +namespace Unity.Services.CloudSave.Authoring.Core.IO +{ + public interface ICloudSaveSimpleResourceLoader + { + Task ReadResource(string path, CancellationToken token); + Task CreateOrUpdateResource(IResourceDeploymentItem deployableItem, CancellationToken token); + Task DeleteResource(IResourceDeploymentItem deploymentItem, CancellationToken token); + } +} diff --git a/Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/IO/IFileSystem.cs b/Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/IO/IFileSystem.cs new file mode 100644 index 0000000..a74ffbd --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/IO/IFileSystem.cs @@ -0,0 +1,19 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Unity.Services.CloudSave.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.CloudSave.Authoring.Core/Model/ClientException.cs b/Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/Model/ClientException.cs new file mode 100644 index 0000000..0661ac8 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/Model/ClientException.cs @@ -0,0 +1,12 @@ +using System; + +namespace Unity.Services.CloudSave.Authoring.Core.Model +{ + public class ClientException : Exception + { + public ClientException(string message, Exception innerExcception) : base(message, innerExcception) + { + + } + } +} diff --git a/Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/Model/Constants.cs b/Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/Model/Constants.cs new file mode 100644 index 0000000..ffb75c0 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/Model/Constants.cs @@ -0,0 +1,11 @@ +namespace Unity.Services.CloudSave.Authoring.Core.Model +{ + public class Constants + { + //TODO: Modify this extension as appropriate + public const string SimpleFileExtension = ".serv"; + public const string Updated = "Updated"; + public const string Created = "Created"; + public const string Deleted = "Deleted"; + } +} diff --git a/Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/Model/IResource.cs b/Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/Model/IResource.cs new file mode 100644 index 0000000..548a4f6 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/Model/IResource.cs @@ -0,0 +1,35 @@ +using System.Runtime.Serialization; +using Unity.Services.DeploymentApi.Editor; + +namespace Unity.Services.CloudSave.Authoring.Core.Model +{ + public interface IResource + { + //TODO: Try not to leak non-human readable IDs to users (non-human readable like GUIDS, UUIDs) + // Default to using the file as the ID, and allow overrides + string Id { get; } + string Name { get; } + + string AStrValue { get; set; } + + NestedObject NestedObj{ get; set; } + } + + + public interface IResourceDeploymentItem : IDeploymentItem, ITypedItem + { + new float Progress { get; set; } + + //TODO: Rename to match your model (e.g. script, entry, pool, etc) + IResource Resource { get; set; } + } + + [DataContract] + public class NestedObject + { + [DataMember] + public bool NestedObjectBoolean { get; set; } + [DataMember] + public string NestedObjectString { get; set; } + } +} diff --git a/Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/Model/SimpleResource.cs b/Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/Model/SimpleResource.cs new file mode 100644 index 0000000..a28549f --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/Model/SimpleResource.cs @@ -0,0 +1,23 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Runtime.CompilerServices; +using System.Runtime.Serialization; +using Unity.Services.DeploymentApi.Editor; + +namespace Unity.Services.CloudSave.Authoring.Core.Model +{ + [DataContract] + public class SimpleResource : IResource + { + [DataMember] + public string Id { get; set; } + [DataMember] + public string Name { get; set; } + [DataMember] + public string AStrValue { get; set; } + [DataMember] + public NestedObject NestedObj { get; set; } + } +} diff --git a/Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/Model/SimpleResourceItem.cs b/Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/Model/SimpleResourceItem.cs new file mode 100644 index 0000000..f09fac4 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/Model/SimpleResourceItem.cs @@ -0,0 +1,94 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Runtime.CompilerServices; +using System.Runtime.Serialization; +using Unity.Services.DeploymentApi.Editor; + +namespace Unity.Services.CloudSave.Authoring.Core.Model +{ + [DataContract] + public class SimpleResourceDeploymentItem : IResourceDeploymentItem + { + internal const string SimpleResourceTypeName = "CloudSave Simple Resource"; + float m_Progress; + DeploymentStatus m_Status; + string m_Path; + + public SimpleResourceDeploymentItem(string name, string path) + { + Name = name; + Path = path; + Type = SimpleResourceTypeName; + } + + public SimpleResourceDeploymentItem(string path) + { + Name = System.IO.Path.GetFileName(path); + Path = path; + Type = SimpleResourceTypeName; + } + + /// + /// Name of the item as shown for user feedback, normally file_name.ext + /// + public string Type { get; } + + public string Name { get; } + public string Path + { + get => m_Path; + set => SetField(ref m_Path, value); + } + + public float Progress + { + get => m_Progress; + set => SetField(ref m_Progress, value); + } + + public IResource Resource { get; set; } + + public DeploymentStatus Status + { + get => m_Status; + set => SetField(ref m_Status, value); + } + + public ObservableCollection States { get; } = new(); + + public override string ToString() + { + if (Path == "Remote") + return Resource.Id; + return $"'{Path}'"; + } + + /// + /// Event will be raised when a property of the instance is changed + /// + public event PropertyChangedEventHandler PropertyChanged; + + /// + /// Sets the field and raises an OnPropertyChanged event. + /// + /// The field to set. + /// The value to set. + /// The callback. + /// Name of the property to set. + /// Type of the parameter. + protected void SetField( + ref T field, + T value, + Action onFieldChanged = null, + [CallerMemberName] string propertyName = null) + { + if (EqualityComparer.Default.Equals(field, value)) + return; + field = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + onFieldChanged?.Invoke(field); + } + } +} diff --git a/Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/Model/Statuses.cs b/Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/Model/Statuses.cs new file mode 100644 index 0000000..5c927e5 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/Model/Statuses.cs @@ -0,0 +1,38 @@ +using System; +using Unity.Services.DeploymentApi.Editor; + + +namespace Unity.Services.CloudSave.Authoring.Core.Model +{ + public static class Statuses + { + public static readonly DeploymentStatus FailedToLoad = new ("Failed to load", string.Empty, SeverityLevel.Error); + + public static DeploymentStatus GetFailedToFetch(string details) + => new ("Failed to fetch", details, SeverityLevel.Error); + public static readonly DeploymentStatus Fetching = new ("Fetching", string.Empty, SeverityLevel.Info); + public static DeploymentStatus GetFetched(string detail) => new ("Fetched", detail, SeverityLevel.Success); + + public static DeploymentStatus GetFailedToDeploy(string details) + => new ("Failed to deploy", details, SeverityLevel.Error); + public static DeploymentStatus GetDeploying(string details = null) + => new ( "Deploying", details ?? string.Empty, SeverityLevel.Info); + public static DeploymentStatus GetDeployed(string details) + => new ("Deployed", details, SeverityLevel.Success); + + public static DeploymentStatus GetFailedToLoad(Exception e, string path) + => new ("Failed to load", $"Failed to load '{path}'. Reason: {e.Message}", SeverityLevel.Error); + + public static DeploymentStatus GetFailedToRead(Exception e, string path) + => new ("Failed to read", $"Failed to read '{path}'. Reason: {e.Message}", SeverityLevel.Error); + + public static DeploymentStatus GetFailedToWrite(Exception e, string path) + => new ("Failed to write", $"Failed to write '{path}'. Reason: {e.Message}", SeverityLevel.Error); + + public static DeploymentStatus GetFailedToSerialize(Exception e, string path) + => new ("Failed to serialize", $"Failed to serialize '{path}'. Reason: {e.Message}", SeverityLevel.Error); + + public static DeploymentStatus GetFailedToDelete(Exception e, string path) + => new ("Failed to serialize", $"Failed to delete '{path}'. Reason: {e.Message}", SeverityLevel.Error); + } +} diff --git a/Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/Service/ICloudSaveClient.cs b/Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/Service/ICloudSaveClient.cs new file mode 100644 index 0000000..d50dbc7 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/Service/ICloudSaveClient.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Unity.Services.CloudSave.Authoring.Core.Model; + +namespace Unity.Services.CloudSave.Authoring.Core.Service +{ + //This is a sample IServiceClient and might not map to your existing admin APIs + public interface ICloudSaveClient + { + Task Initialize(string environmentId, string projectId, CancellationToken cancellationToken); + + Task Get(string id, CancellationToken cancellationToken); + Task Update(IResource resource, CancellationToken cancellationToken); + Task Create(IResource resource, CancellationToken cancellationToken); + Task Delete(IResource resource, CancellationToken cancellationToken); + Task> List(CancellationToken cancellationToken); + Task RawGetRequest(string address, CancellationToken cancellationToken = default); + } +} diff --git a/Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/Unity.Services.CloudSave.Authoring.Core.csproj b/Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/Unity.Services.CloudSave.Authoring.Core.csproj new file mode 100644 index 0000000..925bae6 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/Unity.Services.CloudSave.Authoring.Core.csproj @@ -0,0 +1,25 @@ + + + net8.0 + disable + 0.0.1 + disable + Library + true + true + + 9 + + + + <_Parameter1>$(AssemblyName).UnitTest + <_Parameter1>Unity.Services.Cli.CloudSave + + + <_Parameter1>DynamicProxyGenAssembly2 + + + + + + \ No newline at end of file diff --git a/Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/Validations/DuplicateResourceValidation.cs b/Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/Validations/DuplicateResourceValidation.cs new file mode 100644 index 0000000..dcb527a --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.CloudSave.Authoring.Core/Validations/DuplicateResourceValidation.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Unity.Services.CloudSave.Authoring.Core.Model; + +namespace Unity.Services.CloudSave.Authoring.Core.Validations +{ + static class DuplicateResourceValidation + { + public static List FilterDuplicateResources( + IReadOnlyList resources, + out IReadOnlyList> duplicateGroups) + { + //TODO: Revisit this to use name, or whatever ID is appropriate for your implementation + duplicateGroups = resources + .GroupBy(r => r.Resource.Id) + .Where(g => g.Count() > 1) + .ToList(); + + var hashset = new HashSet(duplicateGroups.Select(g => g.Key)); + + return resources + .Where(r => !hashset.Contains(r.Resource.Id)) + .ToList(); + } + + public static (string, string) GetDuplicateResourceErrorMessages( + IResourceDeploymentItem targetResource, + IReadOnlyList group) + { + var duplicates = group + .Except(new[] { targetResource }) + .ToList(); + + var duplicatesStr = string.Join(", ", duplicates.Select(d => $"'{d.Path}'")); + var shortMessage = $"'{targetResource.Path}' was found duplicated in other files: {duplicatesStr}"; + var message = $"Multiple resources with the same identifier '{targetResource.Resource.Id}' were found. " + + "Only a single resource for a given identifier may be deployed/fetched at the same time. " + + "Give all resources unique identifiers or deploy/fetch them separately to proceed.\n" + + shortMessage; + return (shortMessage, message); + } + } +}