diff --git a/MSStore.API/Packaged/IStorePackagedAPI.cs b/MSStore.API/Packaged/IStorePackagedAPI.cs index 443a23c..9b624d9 100644 --- a/MSStore.API/Packaged/IStorePackagedAPI.cs +++ b/MSStore.API/Packaged/IStorePackagedAPI.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; @@ -32,5 +33,6 @@ public interface IStorePackagedAPI Task UpdatePackageRolloutPercentageAsync(string productId, string submissionId, string? flightId, float percentage, CancellationToken ct = default); Task HaltPackageRolloutAsync(string productId, string submissionId, string? flightId, CancellationToken ct = default); Task FinalizePackageRolloutAsync(string productId, string submissionId, string? flightId, CancellationToken ct = default); + Task> GetAppReviewsAsync(string productId, DateOnly? startDate = null, DateOnly? endDate = null, int? top = null, int? skip = null, string? filter = null, string? orderby = null, CancellationToken ct = default); } } \ No newline at end of file diff --git a/MSStore.API/Packaged/Models/AppReview.cs b/MSStore.API/Packaged/Models/AppReview.cs new file mode 100644 index 0000000..79e2cea --- /dev/null +++ b/MSStore.API/Packaged/Models/AppReview.cs @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace MSStore.API.Packaged.Models +{ + /// + /// A single customer review of an application, as returned by the Microsoft Store + /// analytics API (/v1.0/my/analytics/reviews). + /// + /// + /// Every property is optional. The service omits fields entirely (rather than + /// returning them as null) whenever the data was not captured for a given review. + /// + public class AppReview + { + /// + /// Gets or sets the unique identifier of the review. + /// + public string? Id { get; set; } + + /// + /// Gets or sets the date the review was submitted. The service returns this as a + /// string in US format (for example 3/5/2021 12:48:33 PM) rather than ISO-8601, + /// so it is kept as a string to round-trip exactly. + /// + public string? Date { get; set; } + + public string? ApplicationId { get; set; } + public string? ApplicationName { get; set; } + + /// + /// Gets or sets the ISO 3166 country code of the market the review was submitted in. + /// This is a country, not a language. + /// + public string? Market { get; set; } + + public string? OsVersion { get; set; } + public string? DeviceType { get; set; } + public bool? IsRevised { get; set; } + public string? PackageVersion { get; set; } + public string? DeviceModel { get; set; } + public string? ProductFamily { get; set; } + public long? DeviceRAM { get; set; } + public string? DeviceScreenResolution { get; set; } + public double? DeviceStorageCapacity { get; set; } + public bool? IsTouchEnabled { get; set; } + public string? ReviewerName { get; set; } + public double? Rating { get; set; } + public string? ReviewTitle { get; set; } + public string? ReviewText { get; set; } + public long? HelpfulCount { get; set; } + public long? NotHelpfulCount { get; set; } + public string? ResponseDate { get; set; } + public string? ResponseText { get; set; } + + /// + /// Gets or sets the review title translated into the requested language. This is + /// never returned by the Store, which offers no translation; it is populated by the + /// CLI when translation is requested, and omitted from output otherwise. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? TranslatedReviewTitle { get; set; } + + /// + /// Gets or sets the review text translated into the requested language. This is + /// never returned by the Store, which offers no translation; it is populated by the + /// CLI when translation is requested, and omitted from output otherwise. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? TranslatedReviewText { get; set; } + + /// + /// Gets or sets the language detected in the original review text. The Store returns + /// no language information, so this comes from the translation service and is only + /// present when translation was requested. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? DetectedLanguage { get; set; } + } +} diff --git a/MSStore.API/Packaged/StorePackagedAPI.cs b/MSStore.API/Packaged/StorePackagedAPI.cs index 585870c..7beef03 100644 --- a/MSStore.API/Packaged/StorePackagedAPI.cs +++ b/MSStore.API/Packaged/StorePackagedAPI.cs @@ -42,6 +42,7 @@ public class StorePackagedAPI : IStorePackagedAPI, IDisposable private static readonly CompositeFormat DevCenterUpdatePackageRolloutPercentageTemplate = CompositeFormat.Parse("/v{0}/my/applications/{1}{2}/submissions/{3}/updatepackagerolloutpercentage?percentage={4}"); private static readonly CompositeFormat DevCenterHaltPackageRolloutTemplate = CompositeFormat.Parse("/v{0}/my/applications/{1}{2}/submissions/{3}/haltpackagerollout"); private static readonly CompositeFormat DevCenterFinalizePackageRolloutTemplate = CompositeFormat.Parse("/v{0}/my/applications/{1}{2}/submissions/{3}/finalizepackagerollout"); + private static readonly CompositeFormat DevCenterAnalyticsReviewsTemplate = CompositeFormat.Parse("/v{0}/my/analytics/reviews?applicationId={1}"); private SubmissionClient? _devCenterClient; @@ -663,5 +664,76 @@ public async Task FinalizePackageRolloutAsync(string productId, SourceGenerationContext.GetCustom().PackageRollout, ct); } + + /// + /// The maximum number of reviews the analytics API accepts for the top parameter. + /// Larger values are rejected with InvalidQueryParameters. + /// + public const int MaxReviewsPerRequest = 10000; + + public async Task> GetAppReviewsAsync(string productId, DateOnly? startDate = null, DateOnly? endDate = null, int? top = null, int? skip = null, string? filter = null, string? orderby = null, CancellationToken ct = default) + { + // Arguments are validated before the client state so that a bad value is always + // reported as such, rather than depending on initialization order. + if (top is < 1) + { + throw new ArgumentOutOfRangeException(nameof(top), "The number of reviews to retrieve must be at least 1."); + } + + if (top is > MaxReviewsPerRequest) + { + throw new ArgumentOutOfRangeException(nameof(top), $"The Microsoft Store analytics API accepts at most {MaxReviewsPerRequest} reviews per request."); + } + + if (skip is < 0) + { + throw new ArgumentOutOfRangeException(nameof(skip), "The number of reviews to skip cannot be negative."); + } + + AssertClientInitialized(); + + var url = new StringBuilder(string.Format( + CultureInfo.InvariantCulture, + DevCenterAnalyticsReviewsTemplate, + DevCenterVersion, + Uri.EscapeDataString(productId))); + + if (startDate.HasValue) + { + url.Append(CultureInfo.InvariantCulture, $"&startDate={startDate.Value:yyyy-MM-dd}"); + } + + if (endDate.HasValue) + { + url.Append(CultureInfo.InvariantCulture, $"&endDate={endDate.Value:yyyy-MM-dd}"); + } + + if (top.HasValue) + { + url.Append(CultureInfo.InvariantCulture, $"&top={top.Value}"); + } + + if (skip.HasValue) + { + url.Append(CultureInfo.InvariantCulture, $"&skip={skip.Value}"); + } + + if (!string.IsNullOrEmpty(filter)) + { + url.Append(CultureInfo.InvariantCulture, $"&filter={Uri.EscapeDataString(filter)}"); + } + + if (!string.IsNullOrEmpty(orderby)) + { + url.Append(CultureInfo.InvariantCulture, $"&orderby={Uri.EscapeDataString(orderby)}"); + } + + return await _devCenterClient.InvokeAsync>( + HttpMethod.Get, + url.ToString(), + null, + SourceGenerationContext.GetCustom().PagedResponseAppReview, + ct); + } } } diff --git a/MSStore.API/SourceGenerationContext.cs b/MSStore.API/SourceGenerationContext.cs index 2c36262..991f8a2 100644 --- a/MSStore.API/SourceGenerationContext.cs +++ b/MSStore.API/SourceGenerationContext.cs @@ -50,6 +50,8 @@ namespace MSStore.API.Models [JsonSerializable(typeof(PagedResponse))] [JsonSerializable(typeof(DevCenterFlightSubmission))] [JsonSerializable(typeof(DevCenterFlightSubmissionUpdate))] + [JsonSerializable(typeof(PagedResponse))] + [JsonSerializable(typeof(AppReview))] public partial class SourceGenerationContext : JsonSerializerContext { private static SourceGenerationContext? _default; diff --git a/MSStore.CLI.UnitTests/AzureAITranslatorServiceUnitTests.cs b/MSStore.CLI.UnitTests/AzureAITranslatorServiceUnitTests.cs new file mode 100644 index 0000000..cbebb54 --- /dev/null +++ b/MSStore.CLI.UnitTests/AzureAITranslatorServiceUnitTests.cs @@ -0,0 +1,420 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Net; +using Microsoft.Extensions.Logging.Abstractions; +using MSStore.CLI.Services; +using MSStore.CLI.Services.CredentialManager; +using MSStore.CLI.Services.Translation; + +namespace MSStore.CLI.UnitTests +{ + [TestClass] + public class AzureAITranslatorServiceUnitTests + { + private const string FakeKey = "fake-translator-key"; + + public TestContext TestContext { get; set; } = null!; + + private List _requests = null!; + private List _requestBodies = null!; + private Queue _responses = null!; + private Mock _credentialManager = null!; + private Mock> _configurationManager = null!; + private Mock _environmentInformationService = null!; + private Configurations _configurations = null!; + + [TestInitialize] + public void Init() + { + _requests = []; + _requestBodies = []; + _responses = new Queue(); + + _credentialManager = new Mock(); + _credentialManager + .Setup(x => x.ReadCredential(AzureAITranslatorService.CredentialKeyName)) + .Returns(FakeKey); + + _configurations = new Configurations(); + _configurationManager = new Mock>(); + _configurationManager + .Setup(x => x.LoadAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(() => _configurations); + + // Environment variables are read through this service rather than + // System.Environment, so these tests never mutate process-wide state and cannot + // be affected by whatever the developer happens to have set in their shell. + _environmentInformationService = new Mock(); + } + + [TestMethod] + public async Task TranslateAsyncShouldReturnTranslatedTextAndDetectedLanguage() + { + EnqueueJson(HttpStatusCode.OK, """ + [{"detectedLanguage":{"language":"pt","score":1.0},"translations":[{"text":"A fantastic game","to":"en"}]}] + """); + + var results = await CreateService().TranslateAsync(["Um jogo fantástico"], "en", TestContext.CancellationToken); + + results.Should().HaveCount(1); + results[0]!.Text.Should().Be("A fantastic game"); + results[0]!.DetectedLanguage.Should().Be("pt"); + } + + [TestMethod] + public async Task TranslateAsyncShouldOmitFromParameterSoTheServiceAutoDetects() + { + EnqueueJson(HttpStatusCode.OK, """[{"translations":[{"text":"hi","to":"en"}]}]"""); + + await CreateService().TranslateAsync(["olá"], "en", TestContext.CancellationToken); + + var uri = _requests.Single().RequestUri!.ToString(); + uri.Should().Contain("api-version=3.0"); + uri.Should().Contain("to=en"); + uri.Should().NotContain("from="); + } + + [TestMethod] + public async Task TranslateAsyncShouldSendTheDocumentedRequestBodyShape() + { + EnqueueJson(HttpStatusCode.OK, """[{"translations":[{"text":"hi","to":"en"}]}]"""); + + await CreateService().TranslateAsync(["olá"], "en", TestContext.CancellationToken); + + // A bare JSON array whose elements carry the text under the name the Translator + // reference documents. Pinned so a change to the shared naming policy cannot + // silently alter the wire format. + var body = _requestBodies.Single(); + + body.Should().StartWith("[").And.EndWith("]"); + body.Should().Contain("\"Text\":"); + body.Should().Contain("ol\\u00E1"); + } + + [TestMethod] + public async Task TranslateAsyncShouldNotSendRegionHeaderWhenNoRegionIsConfigured() + { + EnqueueJson(HttpStatusCode.OK, """[{"translations":[{"text":"hi","to":"en"}]}]"""); + + await CreateService().TranslateAsync(["olá"], "en", TestContext.CancellationToken); + + _requests.Single().Headers.Contains("Ocp-Apim-Subscription-Region").Should().BeFalse(); + _requests.Single().Headers.GetValues("Ocp-Apim-Subscription-Key").Single().Should().Be(FakeKey); + } + + [TestMethod] + public async Task TranslateAsyncShouldSendRegionHeaderWhenConfigured() + { + _configurations.TranslatorRegion = "westus2"; + EnqueueJson(HttpStatusCode.OK, """[{"translations":[{"text":"hi","to":"en"}]}]"""); + + await CreateService().TranslateAsync(["olá"], "en", TestContext.CancellationToken); + + _requests.Single().Headers.GetValues("Ocp-Apim-Subscription-Region").Single().Should().Be("westus2"); + } + + [TestMethod] + public async Task TranslateAsyncShouldPreferEnvironmentVariablesOverStoredValues() + { + _environmentInformationService + .Setup(x => x.GetEnvironmentVariable(AzureAITranslatorService.KeyEnvironmentVariable)) + .Returns("env-key"); + _environmentInformationService + .Setup(x => x.GetEnvironmentVariable(AzureAITranslatorService.RegionEnvironmentVariable)) + .Returns("eastus"); + _configurations.TranslatorRegion = "westus2"; + + EnqueueJson(HttpStatusCode.OK, """[{"translations":[{"text":"hi","to":"en"}]}]"""); + + await CreateService().TranslateAsync(["olá"], "en", TestContext.CancellationToken); + + _requests.Single().Headers.GetValues("Ocp-Apim-Subscription-Key").Single().Should().Be("env-key"); + _requests.Single().Headers.GetValues("Ocp-Apim-Subscription-Region").Single().Should().Be("eastus"); + } + + [TestMethod] + public async Task TranslateAsyncShouldTrimEnvironmentSuppliedCredentials() + { + // A key pasted or piped in often carries a trailing newline, which is not valid + // in a header value and would otherwise throw before the request is sent. + _environmentInformationService + .Setup(x => x.GetEnvironmentVariable(AzureAITranslatorService.KeyEnvironmentVariable)) + .Returns(" env-key\n"); + _environmentInformationService + .Setup(x => x.GetEnvironmentVariable(AzureAITranslatorService.RegionEnvironmentVariable)) + .Returns(" eastus \r\n"); + + EnqueueJson(HttpStatusCode.OK, """[{"translations":[{"text":"hi","to":"en"}]}]"""); + + await CreateService().TranslateAsync(["olá"], "en", TestContext.CancellationToken); + + _requests.Single().Headers.GetValues("Ocp-Apim-Subscription-Key").Single().Should().Be("env-key"); + _requests.Single().Headers.GetValues("Ocp-Apim-Subscription-Region").Single().Should().Be("eastus"); + } + + [TestMethod] + public async Task TranslateAsyncShouldTrimStoredCredentials() + { + _credentialManager + .Setup(x => x.ReadCredential(AzureAITranslatorService.CredentialKeyName)) + .Returns($"{FakeKey}\n"); + _configurations.TranslatorRegion = " westus2 "; + + EnqueueJson(HttpStatusCode.OK, """[{"translations":[{"text":"hi","to":"en"}]}]"""); + + await CreateService().TranslateAsync(["olá"], "en", TestContext.CancellationToken); + + _requests.Single().Headers.GetValues("Ocp-Apim-Subscription-Key").Single().Should().Be(FakeKey); + _requests.Single().Headers.GetValues("Ocp-Apim-Subscription-Region").Single().Should().Be("westus2"); + } + + [TestMethod] + public async Task TranslateAsyncShouldTreatWhitespaceOnlyKeyAsMissing() + { + _credentialManager + .Setup(x => x.ReadCredential(It.IsAny())) + .Returns(" "); + + var act = async () => await CreateService().TranslateAsync(["olá"], "en", TestContext.CancellationToken); + + (await act.Should().ThrowAsync()) + .WithMessage("*MSSTORE_TRANSLATOR_KEY*set-translator-key*"); + } + + [TestMethod] + public async Task TranslateAsyncShouldSkipEmptyEntriesButKeepPositions() + { + EnqueueJson(HttpStatusCode.OK, """ + [{"translations":[{"text":"one","to":"en"}]},{"translations":[{"text":"two","to":"en"}]}] + """); + + var results = await CreateService().TranslateAsync(["um", null, " ", "dois"], "en", TestContext.CancellationToken); + + results.Should().HaveCount(4); + results[0]!.Text.Should().Be("one"); + results[1].Should().BeNull(); + results[2].Should().BeNull(); + results[3]!.Text.Should().Be("two"); + } + + [TestMethod] + public async Task TranslateAsyncShouldNotCallTheServiceWhenEverythingIsEmpty() + { + var results = await CreateService().TranslateAsync([null, " "], "en", TestContext.CancellationToken); + + results.Should().HaveCount(2); + _requests.Should().BeEmpty(); + } + + [TestMethod] + public async Task TranslateAsyncShouldSplitBatchesThatExceedTheElementLimit() + { + var texts = new List(); + for (var i = 0; i < AzureAITranslatorService.MaxElementsPerRequest + 5; i++) + { + texts.Add($"t{i}"); + } + + EnqueueJson(HttpStatusCode.OK, BuildTranslationsJson(AzureAITranslatorService.MaxElementsPerRequest)); + EnqueueJson(HttpStatusCode.OK, BuildTranslationsJson(5)); + + var results = await CreateService().TranslateAsync(texts, "en", TestContext.CancellationToken); + + _requests.Should().HaveCount(2); + results.Should().HaveCount(AzureAITranslatorService.MaxElementsPerRequest + 5); + results.Should().AllSatisfy(r => r.Should().NotBeNull()); + } + + [TestMethod] + public async Task TranslateAsyncShouldSplitBatchesThatExceedTheCharacterLimit() + { + var big = new string('a', (AzureAITranslatorService.MaxCharactersPerRequest / 2) + 1); + + EnqueueJson(HttpStatusCode.OK, BuildTranslationsJson(1)); + EnqueueJson(HttpStatusCode.OK, BuildTranslationsJson(1)); + + await CreateService().TranslateAsync([big, big], "en", TestContext.CancellationToken); + + _requests.Should().HaveCount(2); + } + + [TestMethod] + public async Task TranslateAsyncShouldThrowWhenNoKeyIsConfigured() + { + _credentialManager + .Setup(x => x.ReadCredential(It.IsAny())) + .Returns(string.Empty); + + var act = async () => await CreateService().TranslateAsync(["olá"], "en", TestContext.CancellationToken); + + (await act.Should().ThrowAsync()) + .WithMessage("*MSSTORE_TRANSLATOR_KEY*set-translator-key*"); + } + + [TestMethod] + public async Task TranslateAsyncShouldMapAuthenticationErrors() + { + // The service returns 'code' as a JSON number, not a string. + EnqueueJson(HttpStatusCode.Unauthorized, """ + {"error":{"code":401001,"message":"The request is not authorized because credentials are missing or invalid."}} + """); + + var act = async () => await CreateService().TranslateAsync(["olá"], "en", TestContext.CancellationToken); + + (await act.Should().ThrowAsync()) + .WithMessage("*rejected the credentials*"); + } + + [TestMethod] + public async Task TranslateAsyncShouldMapSpeechKeyError() + { + EnqueueJson(HttpStatusCode.Unauthorized, """ + {"error":{"code":401015,"message":"The credentials provided are for the Speech API."}} + """); + + var act = async () => await CreateService().TranslateAsync(["olá"], "en", TestContext.CancellationToken); + + (await act.Should().ThrowAsync()) + .WithMessage("*Speech API*"); + } + + [TestMethod] + public async Task TranslateAsyncShouldMapQuotaExceededError() + { + EnqueueJson(HttpStatusCode.Forbidden, """ + {"error":{"code":403001,"message":"The operation isn't allowed because the subscription exceeded its free quota."}} + """); + + var act = async () => await CreateService().TranslateAsync(["olá"], "en", TestContext.CancellationToken); + + (await act.Should().ThrowAsync()) + .WithMessage("*exceeded its free quota*"); + } + + [TestMethod] + public async Task TranslateAsyncShouldRetryThrottledRequests() + { + EnqueueJson(HttpStatusCode.TooManyRequests, """{"error":{"code":429001,"message":"Too many requests."}}"""); + EnqueueJson(HttpStatusCode.OK, """[{"translations":[{"text":"hi","to":"en"}]}]"""); + + var results = await CreateService().TranslateAsync(["olá"], "en", TestContext.CancellationToken); + + _requests.Should().HaveCount(2); + results[0]!.Text.Should().Be("hi"); + } + + [TestMethod] + public async Task TranslateAsyncShouldGiveUpAfterRepeatedThrottling() + { + for (var i = 0; i < 3; i++) + { + EnqueueJson(HttpStatusCode.TooManyRequests, """{"error":{"code":429001,"message":"Too many requests."}}"""); + } + + var act = async () => await CreateService().TranslateAsync(["olá"], "en", TestContext.CancellationToken); + + (await act.Should().ThrowAsync()) + .WithMessage("*throttling*"); + _requests.Should().HaveCount(3); + } + + [TestMethod] + public async Task ResolveLanguageAsyncShouldReturnTheCanonicalCasing() + { + EnqueueJson(HttpStatusCode.OK, """ + {"translation":{"en":{"name":"English"},"pt-PT":{"name":"Portuguese (Portugal)"}}} + """); + + var resolved = await CreateService().ResolveLanguageAsync("PT-pt", TestContext.CancellationToken); + + resolved.Should().Be("pt-PT"); + } + + [TestMethod] + public async Task ResolveLanguageAsyncShouldNotAuthenticateTheLanguagesCall() + { + EnqueueJson(HttpStatusCode.OK, """{"translation":{"en":{"name":"English"}}}"""); + + await CreateService().ResolveLanguageAsync("en", TestContext.CancellationToken); + + _requests.Single().Headers.Contains("Ocp-Apim-Subscription-Key").Should().BeFalse(); + } + + [TestMethod] + public async Task ResolveLanguageAsyncShouldRejectUnsupportedLanguages() + { + EnqueueJson(HttpStatusCode.OK, """{"translation":{"en":{"name":"English"}}}"""); + + var act = async () => await CreateService().ResolveLanguageAsync("zz", TestContext.CancellationToken); + + (await act.Should().ThrowAsync()) + .WithMessage("*not a language supported*"); + } + + [TestMethod] + public async Task ResolveLanguageAsyncShouldFallBackWhenTheLanguageListIsUnavailable() + { + EnqueueJson(HttpStatusCode.ServiceUnavailable, "{}"); + + var resolved = await CreateService().ResolveLanguageAsync("pt", TestContext.CancellationToken); + + resolved.Should().Be("pt"); + } + + private static string BuildTranslationsJson(int count) + { + var items = Enumerable.Repeat("""{"translations":[{"text":"x","to":"en"}]}""", count); + return $"[{string.Join(',', items)}]"; + } + + private void EnqueueJson(HttpStatusCode statusCode, string json) + { + _responses.Enqueue(new HttpResponseMessage(statusCode) + { + Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json") + }); + } + + private AzureAITranslatorService CreateService() + { + var handler = new StubHttpMessageHandler(_requests, _requestBodies, _responses); + + var httpClientFactory = new Mock(); + httpClientFactory + .Setup(x => x.CreateClient(It.IsAny())) + .Returns(() => new HttpClient(handler, disposeHandler: false) + { + BaseAddress = new Uri("https://api.cognitive.microsofttranslator.com") + }); + + return new AzureAITranslatorService( + httpClientFactory.Object, + _credentialManager.Object, + _configurationManager.Object, + _environmentInformationService.Object, + NullLogger.Instance); + } + + private sealed class StubHttpMessageHandler(List requests, List requestBodies, Queue responses) : HttpMessageHandler + { + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + // The body has to be read here: the caller disposes the request, and with it + // the content, as soon as the send completes. + requestBodies.Add(request.Content == null + ? string.Empty + : await request.Content.ReadAsStringAsync(cancellationToken)); + + requests.Add(request); + + return responses.Count > 0 + ? responses.Dequeue() + : new HttpResponseMessage(HttpStatusCode.InternalServerError) + { + Content = new StringContent("{}", System.Text.Encoding.UTF8, "application/json") + }; + } + } + } +} diff --git a/MSStore.CLI.UnitTests/BaseCommandLineTest.cs b/MSStore.CLI.UnitTests/BaseCommandLineTest.cs index 8a13f5c..7937f14 100644 --- a/MSStore.CLI.UnitTests/BaseCommandLineTest.cs +++ b/MSStore.CLI.UnitTests/BaseCommandLineTest.cs @@ -3,6 +3,7 @@ using System.CommandLine; using System.CommandLine.Invocation; +using System.Globalization; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Text; @@ -25,11 +26,15 @@ using MSStore.CLI.Services.PWABuilder; using MSStore.CLI.Services.Telemetry; using MSStore.CLI.Services.TokenManager; +using MSStore.CLI.Services.Translation; using Spectre.Console; namespace MSStore.CLI.UnitTests { - public class BaseCommandLineTest + /// + /// Shared host, mocks and console capture for command-line tests. + /// + public partial class BaseCommandLineTest { internal Mock FakeConsole { get; private set; } = null!; internal Mock> FakeConfigurationManager { get; private set; } = null!; @@ -46,6 +51,7 @@ public class BaseCommandLineTest internal Mock NuGetPackageManager { get; private set; } = null!; internal Mock ZipFileManager { get; private set; } = null!; internal Mock EnvironmentInformationService { get; private set; } = null!; + internal Mock FakeTranslationService { get; private set; } = null!; internal List UserNames { get; } = []; internal List Secrets { get; } = []; @@ -87,6 +93,43 @@ public class BaseCommandLineTest } ]; + protected List FakeReviews { get; } = + [ + new AppReview + { + Id = "6BE543FF-1C9C-4534-ACED-AF8B4FBE0316", + Date = "3/5/2021 12:48:33 PM", + Market = "US", + Rating = 5, + ReviewerName = "FakeReviewer1", + ReviewTitle = "Great app", + ReviewText = "This app is great", + HelpfulCount = 3, + NotHelpfulCount = 0 + }, + new AppReview + { + Id = "7CF654AA-2D8D-4645-BDFE-B09C5FCA1427", + Date = "3/6/2021 09:12:01 AM", + Market = "BR", + Rating = 4, + ReviewerName = "FakeReviewer2", + ReviewTitle = "Um jogo fantástico", + ReviewText = "Gostei muito", + ResponseDate = "3/7/2021 10:00:00 AM", + ResponseText = "Obrigado!" + }, + + // The analytics API omits fields entirely rather than returning them as null, + // so at least one fixture has to be sparse. + new AppReview + { + Id = "8DA765BB-3E7E-4756-CEAF-C1AD6FDB2538", + Date = "3/7/2021 08:00:00 PM", + Rating = 1 + } + ]; + internal static Organization DefaultOrganization { get; } = new Organization { Id = new Guid("F3C1CCB6-09C0-4BAB-BABA-C034BFB60EF9") @@ -271,6 +314,11 @@ public void Initialize() TokenManager = new Mock(); + FakeTranslationService = new Mock(); + FakeTranslationService + .Setup(x => x.ResolveLanguageAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((string language, CancellationToken ct) => language); + _hostBuilder = Host.CreateDefaultBuilder(null) .UseEnvironment("CLI") .ConfigureServices((hostContext, services) => @@ -310,7 +358,8 @@ public void Initialize() .AddScoped(sp => PWAAppInfoManager.Object) .AddScoped(sp => ElectronManifestManager.Object) .AddScoped(sp => NuGetPackageManager.Object) - .AddScoped(sp => AppXManifestManager.Object); + .AddScoped(sp => AppXManifestManager.Object) + .AddScoped(sp => FakeTranslationService.Object); services.AddLogging(builder => { @@ -510,6 +559,71 @@ protected void AddFakeApps() .ReturnsAsync((string productId, CancellationToken ct) => FakeApps.First(a => a.Id == productId)); } + protected void AddFakeReviews() + { + FakeStorePackagedAPI + .Setup(x => x.GetAppReviewsAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync((string productId, DateOnly? startDate, DateOnly? endDate, int? top, int? skip, string? filter, string? orderby, CancellationToken ct) => + { + var reviews = FakeReviews.AsEnumerable(); + + // Mirrors the subset of the analytics API's filter syntax that the CLI emits. + if (filter != null) + { + var idMatch = System.Text.RegularExpressions.Regex.Match(filter, @"id eq '([^']*)'"); + if (idMatch.Success) + { + reviews = reviews.Where(r => string.Equals(r.Id, idMatch.Groups[1].Value, StringComparison.OrdinalIgnoreCase)); + } + + var ratingMatch = System.Text.RegularExpressions.Regex.Match(filter, @"rating eq (\d+)"); + if (ratingMatch.Success) + { + var rating = double.Parse(ratingMatch.Groups[1].Value, CultureInfo.InvariantCulture); + reviews = reviews.Where(r => r.Rating == rating); + } + + var marketMatch = System.Text.RegularExpressions.Regex.Match(filter, @"market eq '([^']*)'"); + if (marketMatch.Success) + { + reviews = reviews.Where(r => string.Equals(r.Market, marketMatch.Groups[1].Value, StringComparison.OrdinalIgnoreCase)); + } + } + + var value = reviews.Skip(skip ?? 0).Take(top ?? int.MaxValue).ToList(); + + return new PagedResponse + { + Value = value, + TotalCount = value.Count + }; + }); + } + + /// + /// Makes the fake translation service echo each text back prefixed with the target + /// language, so tests can assert that translated content reached the output. + /// + protected void AddFakeTranslations(string detectedLanguage = "pt") + { + FakeTranslationService + .Setup(x => x.TranslateAsync(It.IsAny>(), It.IsAny(), It.IsAny())) + .ReturnsAsync((IReadOnlyList texts, string targetLanguage, CancellationToken ct) => + texts + .Select(t => string.IsNullOrWhiteSpace(t) + ? null + : new TranslationResult($"[{targetLanguage}] {t}", detectedLanguage)) + .ToList()); + } + protected void AddFakeFlights() { FakeStorePackagedAPI @@ -862,9 +976,41 @@ protected void SetupBasedOnTestDataProjectSubPath(DirectoryInfo dirInfo, string[ outputCapture.Captured.ToString().Should().NotContain("💥"); } - return (Output: outputCapture.Captured.ToString() ?? string.Empty, Error: errorCapture.Captured.ToString() ?? string.Empty); + return (Output: StripAnsi(outputCapture.Captured.ToString()), Error: StripAnsi(errorCapture.Captured.ToString())); } + /// + /// Removes ANSI escape sequences so assertions can match the visible text. + /// + /// + /// Spectre emits colour and style codes only when the underlying stream negotiates + /// ANSI support, which differs between a developer machine and CI. Without stripping, + /// an assertion on a string that spans a markup boundary (for example the "no reviews" + /// in "This application has [bold][u]no[/] reviews[/].") passes locally and fails on CI. + /// + /// The captured console output. + /// The output with escape sequences removed. + internal static string StripAnsi(string? value) + { + return value == null ? string.Empty : AnsiEscapeSequence().Replace(value, string.Empty); + } + + /// + /// Matches ANSI escape sequences per ECMA-48. + /// + /// + /// Covers more than the colour codes: CSI sequences may carry private parameters, as + /// in the cursor hide/show pair a status spinner emits, and OSC sequences carry the + /// hyperlinks produced by Spectre's link markup. Matching only digits and semicolons + /// would leave both in the captured output. + /// + /// The compiled regular expression. + [System.Text.RegularExpressions.GeneratedRegex( + """ + \u001b\[[0-?]*[ -/]*[@-~]|\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)|\u001b[@-_] + """)] + private static partial System.Text.RegularExpressions.Regex AnsiEscapeSequence(); + private OutputCapture RefreshAnsiConsole() { var errorCapture = new OutputCapture(Console.Error); diff --git a/MSStore.CLI.UnitTests/ReviewsCommandUnitTests.cs b/MSStore.CLI.UnitTests/ReviewsCommandUnitTests.cs new file mode 100644 index 0000000..b599ff9 --- /dev/null +++ b/MSStore.CLI.UnitTests/ReviewsCommandUnitTests.cs @@ -0,0 +1,412 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using MSStore.CLI.Services.Translation; + +namespace MSStore.CLI.UnitTests +{ + [TestClass] + public class ReviewsCommandUnitTests : BaseCommandLineTest + { + [TestInitialize] + public void Init() + { + FakeLogin(); + AddDefaultFakeAccount(); + AddFakeApps(); + AddFakeReviews(); + } + + [TestMethod] + public async Task ReviewsListCommandShouldReturnZero() + { + var result = await ParseAndInvokeAsync( + [ + "reviews", + "list", + "9PN3ABCDEFGA" + ]); + + // The table is human-facing output, so it goes to the injected console (stderr). + result.Error.Should().Contain("FakeReviewer1"); + result.Error.Should().Contain("Great app"); + } + + [TestMethod] + public async Task ReviewsListCommandShouldRenderReviewsMissingOptionalFields() + { + // The analytics API omits fields entirely rather than returning them as null, + // so a sparse review must not break rendering. + var result = await ParseAndInvokeAsync( + [ + "reviews", + "list", + "9PN3ABCDEFGA" + ]); + + result.Error.Should().Contain("Um jogo fantástico"); + result.Error.Should().Contain("*---- (1)"); + } + + [TestMethod] + public async Task ReviewsListCommandShouldFilterByMarket() + { + var result = await ParseAndInvokeAsync( + [ + "reviews", + "list", + "9PN3ABCDEFGA", + "--market", + "BR" + ]); + + result.Error.Should().Contain("Um jogo fantástico"); + result.Error.Should().NotContain("Great app"); + } + + [TestMethod] + public async Task ReviewsListCommandShouldFilterByRating() + { + var result = await ParseAndInvokeAsync( + [ + "reviews", + "list", + "9PN3ABCDEFGA", + "--rating", + "5" + ]); + + result.Error.Should().Contain("Great app"); + result.Error.Should().NotContain("Um jogo fantástico"); + } + + [TestMethod] + public async Task ReviewsListCommandShouldRejectInvalidRating() + { + var result = await ParseAndInvokeAsync( + [ + "reviews", + "list", + "9PN3ABCDEFGA", + "--rating", + "9" + ], + -1); + + result.Error.Should().Contain("--rating must be between 1 and 5."); + } + + [TestMethod] + public async Task ReviewsListCommandShouldRejectTooLargeTop() + { + var result = await ParseAndInvokeAsync( + [ + "reviews", + "list", + "9PN3ABCDEFGA", + "--top", + "10001" + ], + -1); + + result.Error.Should().Contain("--top cannot be greater than 10000."); + } + + [TestMethod] + [DataRow("0")] + [DataRow("-5")] + public async Task ReviewsListCommandShouldRejectNonPositiveTop(string top) + { + var result = await ParseAndInvokeAsync( + [ + "reviews", + "list", + "9PN3ABCDEFGA", + "--top", + top + ], + -1); + + result.Error.Should().Contain("--top must be at least 1."); + } + + [TestMethod] + public async Task ReviewsListCommandShouldRejectNegativeSkip() + { + var result = await ParseAndInvokeAsync( + [ + "reviews", + "list", + "9PN3ABCDEFGA", + "--skip", + "-3" + ], + -1); + + result.Error.Should().Contain("--skip cannot be negative."); + } + + [TestMethod] + public async Task ReviewsListCommandIsNotSupportedForUnpackagedApps() + { + var result = await ParseAndInvokeAsync( + [ + "reviews", + "list", + Guid.Empty.ToString() + ], + -1); + + result.Error.Should().Contain("This command is not supported for unpackaged applications."); + } + + [TestMethod] + public async Task ReviewsListCommandShouldReportWhenThereAreNoReviews() + { + FakeReviews.Clear(); + + var result = await ParseAndInvokeAsync( + [ + "reviews", + "list", + "9PN3ABCDEFGA" + ]); + + result.Error.Should().Contain("This application has no reviews."); + + // No date or filter option was passed, so the service returns reviews from every + // date. Referring to a period or filters here would misdirect the user. + result.Error.Should().NotContain("period"); + result.Error.Should().NotContain("filters"); + } + + [TestMethod] + public async Task ReviewsListCommandShouldMentionThePeriodWhenNarrowedByDate() + { + FakeReviews.Clear(); + + var result = await ParseAndInvokeAsync( + [ + "reviews", + "list", + "9PN3ABCDEFGA", + "--startDate", + "2024-01-01" + ]); + + result.Error.Should().Contain("for the requested period."); + } + + [TestMethod] + public async Task ReviewsListCommandShouldMentionFiltersWhenNarrowedByFilter() + { + var result = await ParseAndInvokeAsync( + [ + "reviews", + "list", + "9PN3ABCDEFGA", + "--market", + "ZZ" + ]); + + result.Error.Should().Contain("matching the requested filters."); + } + + [TestMethod] + public async Task ReviewsListCommandShouldMentionBothWhenNarrowedByDateAndFilter() + { + var result = await ParseAndInvokeAsync( + [ + "reviews", + "list", + "9PN3ABCDEFGA", + "--startDate", + "2024-01-01", + "--rating", + "2" + ]); + + result.Error.Should().Contain("matching the requested period and filters."); + } + + [TestMethod] + public async Task ReviewsListCommandShouldTranslateWhenRequested() + { + AddFakeTranslations(); + + var result = await ParseAndInvokeAsync( + [ + "reviews", + "list", + "9PN3ABCDEFGA", + "--translate" + ]); + + result.Error.Should().Contain("[en] Great app"); + } + + [TestMethod] + public async Task ReviewsListCommandShouldTranslateIntoTheRequestedLanguage() + { + AddFakeTranslations(); + + var result = await ParseAndInvokeAsync( + [ + "reviews", + "list", + "9PN3ABCDEFGA", + "--translate", + "pt" + ]); + + result.Error.Should().Contain("[pt] Great app"); + } + + [TestMethod] + public async Task ReviewsListCommandShouldReportMissingTranslatorKey() + { + FakeTranslationService + .Setup(x => x.TranslateAsync(It.IsAny>(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new TranslationException("No Azure AI Translator key is configured.")); + + var result = await ParseAndInvokeAsync( + [ + "reviews", + "list", + "9PN3ABCDEFGA", + "--translate" + ], + -1); + + result.Error.Should().Contain("No Azure AI Translator key is configured."); + } + + [TestMethod] + public async Task ReviewsGetCommandShouldReturnJsonForKnownReview() + { + var reviewId = FakeReviews[0].Id!; + + var result = await ParseAndInvokeAsync( + [ + "reviews", + "get", + "9PN3ABCDEFGA", + reviewId + ]); + + result.Output.Should().Contain($"\"Id\": \"{reviewId}\""); + result.Output.Should().Contain("\"ReviewTitle\": \"Great app\""); + } + + [TestMethod] + public async Task ReviewsGetCommandShouldNotEmitTranslatedFieldsWhenNotTranslating() + { + var result = await ParseAndInvokeAsync( + [ + "reviews", + "get", + "9PN3ABCDEFGA", + FakeReviews[0].Id! + ]); + + result.Output.Should().NotContain("TranslatedReviewTitle"); + result.Output.Should().NotContain("DetectedLanguage"); + } + + [TestMethod] + public async Task ReviewsGetCommandShouldEmitTranslatedFieldsWhenTranslating() + { + AddFakeTranslations(); + + var result = await ParseAndInvokeAsync( + [ + "reviews", + "get", + "9PN3ABCDEFGA", + FakeReviews[1].Id!, + "--translate" + ]); + + // System.Text.Json escapes non-ASCII by default, so the accented characters are + // \u-escaped in the payload. That is valid JSON and decodes back correctly; the + // same encoder is used by every other command that emits JSON. + result.Output.Should().Contain("\"TranslatedReviewTitle\": \"[en] Um jogo fant\\u00E1stico\""); + result.Output.Should().Contain("\"DetectedLanguage\": \"pt\""); + } + + [TestMethod] + public async Task ReviewsGetCommandShouldReturnErrorIfNonExistingReview() + { + var result = await ParseAndInvokeAsync( + [ + "reviews", + "get", + "9PN3ABCDEFGA", + "00000000-0000-0000-0000-000000000000" + ], + -1); + + result.Error.Should().Contain("Could not find review with ID"); + + // No date option was passed, so no date parameters are sent and the service + // searches every review. Suggesting --startDate here would misdirect the user. + result.Error.Should().NotContain("--startDate"); + } + + [TestMethod] + public async Task ReviewsGetCommandShouldSuggestWideningAnExplicitDateRange() + { + var result = await ParseAndInvokeAsync( + [ + "reviews", + "get", + "9PN3ABCDEFGA", + "00000000-0000-0000-0000-000000000000", + "--startDate", + "2024-01-01" + ], + -1); + + result.Error.Should().Contain("within the requested date range"); + } + + [TestMethod] + public async Task ReviewsGetCommandShouldNotClaimTheReviewIsMissingWhenTranslationFails() + { + FakeTranslationService + .Setup(x => x.TranslateAsync(It.IsAny>(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new TranslationException("No Azure AI Translator key is configured.")); + + var result = await ParseAndInvokeAsync( + [ + "reviews", + "get", + "9PN3ABCDEFGA", + FakeReviews[0].Id!, + "--translate" + ], + -1); + + result.Error.Should().Contain("No Azure AI Translator key is configured."); + + // The review was found; only the translation failed, so pointing the user at + // --startDate would misdirect them. + result.Error.Should().NotContain("Could not find review with ID"); + } + + [TestMethod] + public async Task ReviewsGetCommandIsNotSupportedForUnpackagedApps() + { + var result = await ParseAndInvokeAsync( + [ + "reviews", + "get", + Guid.Empty.ToString(), + FakeReviews[0].Id! + ], + -1); + + result.Error.Should().Contain("This command is not supported for unpackaged applications."); + } + } +} diff --git a/MSStore.CLI.UnitTests/SetTranslatorKeyCommandUnitTests.cs b/MSStore.CLI.UnitTests/SetTranslatorKeyCommandUnitTests.cs new file mode 100644 index 0000000..0aa9c63 --- /dev/null +++ b/MSStore.CLI.UnitTests/SetTranslatorKeyCommandUnitTests.cs @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using MSStore.CLI.Services; +using MSStore.CLI.Services.Translation; + +namespace MSStore.CLI.UnitTests +{ + [TestClass] + public class SetTranslatorKeyCommandUnitTests : BaseCommandLineTest + { + [TestInitialize] + public void Init() + { + FakeLogin(); + AddDefaultFakeAccount(); + } + + [TestMethod] + public async Task SetTranslatorKeyShouldStoreTheKeyInTheSecureStore() + { + var result = await ParseAndInvokeAsync( + [ + "settings", + "set-translator-key", + "my-translator-key" + ]); + + result.Error.Should().Contain("stored"); + + CredentialManager.Verify( + x => x.WriteCredential(AzureAITranslatorService.CredentialKeyName, "my-translator-key"), + Times.Once); + } + + [TestMethod] + public async Task SetTranslatorKeyShouldTrimTheKeyAndRegionBeforePersisting() + { + // Whitespace around a pasted value is not valid in a request header, so it must + // never reach the secure store or settings.json. + Configurations? saved = null; + FakeConfigurationManager + .Setup(x => x.SaveAsync(It.IsAny(), It.IsAny())) + .Callback((Configurations c, CancellationToken ct) => saved = c) + .Returns(Task.CompletedTask); + + await ParseAndInvokeAsync( + [ + "settings", + "set-translator-key", + " my-translator-key\t", + "--region", + " westus2 " + ]); + + CredentialManager.Verify( + x => x.WriteCredential(AzureAITranslatorService.CredentialKeyName, "my-translator-key"), + Times.Once); + + saved.Should().NotBeNull(); + saved!.TranslatorRegion.Should().Be("westus2"); + } + + [TestMethod] + public async Task SetTranslatorKeyShouldRequireAKey() + { + var result = await ParseAndInvokeAsync( + [ + "settings", + "set-translator-key" + ], + -1); + + result.Error.Should().Contain("A key is required."); + } + + [TestMethod] + public async Task SetTranslatorKeyShouldClearStoredValues() + { + var result = await ParseAndInvokeAsync( + [ + "settings", + "set-translator-key", + "--clear" + ]); + + result.Error.Should().Contain("cleared"); + + CredentialManager.Verify( + x => x.ClearCredentials(AzureAITranslatorService.CredentialKeyName), + Times.Once); + } + } +} diff --git a/MSStore.CLI.UnitTests/StorePackagedAPIReviewsUnitTests.cs b/MSStore.CLI.UnitTests/StorePackagedAPIReviewsUnitTests.cs new file mode 100644 index 0000000..eac5a19 --- /dev/null +++ b/MSStore.CLI.UnitTests/StorePackagedAPIReviewsUnitTests.cs @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using MSStore.API.Models; +using MSStore.API.Packaged; + +namespace MSStore.CLI.UnitTests +{ + [TestClass] + public class StorePackagedAPIReviewsUnitTests + { + public TestContext TestContext { get; set; } = null!; + + /// + /// Paging arguments are validated before the client is used, so an invalid value is + /// always reported as such rather than reaching the service, which rejects it with an + /// opaque error. + /// + /// The API instance under test. + private static StorePackagedAPI CreateApi() => new( + new StoreConfigurations + { + SellerId = 1, + TenantId = new Guid("41261775-DB6D-4B44-9A36-7EB8565C7D22"), + ClientId = new Guid("3F0BCAEF-6334-48CF-837F-81CB0F1F2C45") + }, + "fakeSecret", + null, + null); + + [TestMethod] + [DataRow(0)] + [DataRow(-1)] + public async Task GetAppReviewsAsyncShouldRejectNonPositiveTop(int top) + { + using var api = CreateApi(); + + var act = async () => await api.GetAppReviewsAsync("9PN3ABCDEFGA", top: top, ct: TestContext.CancellationToken); + + (await act.Should().ThrowAsync()) + .WithParameterName(nameof(top)); + } + + [TestMethod] + public async Task GetAppReviewsAsyncShouldRejectTopAboveTheServiceMaximum() + { + using var api = CreateApi(); + + var act = async () => await api.GetAppReviewsAsync( + "9PN3ABCDEFGA", + top: StorePackagedAPI.MaxReviewsPerRequest + 1, + ct: TestContext.CancellationToken); + + (await act.Should().ThrowAsync()) + .WithParameterName("top"); + } + + [TestMethod] + public async Task GetAppReviewsAsyncShouldRejectNegativeSkip() + { + using var api = CreateApi(); + + var act = async () => await api.GetAppReviewsAsync("9PN3ABCDEFGA", skip: -1, ct: TestContext.CancellationToken); + + (await act.Should().ThrowAsync()) + .WithParameterName("skip"); + } + } +} diff --git a/MSStore.CLI.UnitTests/StripAnsiUnitTests.cs b/MSStore.CLI.UnitTests/StripAnsiUnitTests.cs new file mode 100644 index 0000000..98cfdfc --- /dev/null +++ b/MSStore.CLI.UnitTests/StripAnsiUnitTests.cs @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +namespace MSStore.CLI.UnitTests +{ + /// + /// Guards the escape-sequence stripping that keeps console assertions from depending on + /// whether the host negotiated ANSI support. + /// + [TestClass] + public class StripAnsiUnitTests + { + private const string Esc = "\u001b"; + + [TestMethod] + public void ShouldRemoveColourAndStyleCodes() + { + BaseCommandLineTest.StripAnsi($"This application has {Esc}[1;4mno{Esc}[0m{Esc}[1m reviews{Esc}[0m.") + .Should().Be("This application has no reviews."); + } + + [TestMethod] + public void ShouldRemovePrivateModeCodes() + { + // A status spinner hides and shows the cursor with private parameter bytes. + BaseCommandLineTest.StripAnsi($"{Esc}[?25lRetrieving Reviews{Esc}[?25h") + .Should().Be("Retrieving Reviews"); + } + + [TestMethod] + public void ShouldRemoveHyperlinks() + { + // Spectre's link markup emits OSC 8 sequences terminated by a string terminator. + BaseCommandLineTest.StripAnsi($"see {Esc}]8;;https://aka.ms/privacy{Esc}\\the terms{Esc}]8;;{Esc}\\ here") + .Should().Be("see the terms here"); + } + + [TestMethod] + public void ShouldRemoveBellTerminatedHyperlinks() + { + BaseCommandLineTest.StripAnsi($"{Esc}]8;;https://example.com\u0007link{Esc}]8;;\u0007") + .Should().Be("link"); + } + + [TestMethod] + public void ShouldLeavePlainTextUntouched() + { + BaseCommandLineTest.StripAnsi("This application has no reviews.") + .Should().Be("This application has no reviews."); + } + + [TestMethod] + public void ShouldTreatNullAsEmpty() + { + BaseCommandLineTest.StripAnsi(null).Should().BeEmpty(); + } + + [TestMethod] + public void ShouldPreserveNonAsciiContent() + { + BaseCommandLineTest.StripAnsi($"{Esc}[1mUm jogo fantástico{Esc}[0m") + .Should().Be("Um jogo fantástico"); + } + } +} diff --git a/MSStore.CLI/Commands/Reviews/GetCommand.cs b/MSStore.CLI/Commands/Reviews/GetCommand.cs new file mode 100644 index 0000000..5c8eb4d --- /dev/null +++ b/MSStore.CLI/Commands/Reviews/GetCommand.cs @@ -0,0 +1,139 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.CommandLine; +using System.CommandLine.Invocation; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.ApplicationInsights; +using Microsoft.Extensions.Logging; +using MSStore.API; +using MSStore.API.Models; +using MSStore.API.Packaged.Models; +using MSStore.CLI.Helpers; +using MSStore.CLI.Services; +using MSStore.CLI.Services.Translation; +using Spectre.Console; + +namespace MSStore.CLI.Commands.Reviews +{ + internal class GetCommand : Command + { + public GetCommand() + : base("get", "Retrieves the details of a single review.") + { + Arguments.Add(ReviewsCommand.ProductIdArgument); + Arguments.Add(ReviewsCommand.ReviewIdArgument); + Options.Add(ReviewsCommand.StartDateOption); + Options.Add(ReviewsCommand.EndDateOption); + Options.Add(ReviewsCommand.TranslateOption); + } + + public class Handler( + ILogger logger, + IStoreAPIFactory storeAPIFactory, + ITranslationService translationService, + IAnsiConsole ansiConsole, + TelemetryClient telemetryClient) : AsynchronousCommandLineAction + { + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + private readonly IStoreAPIFactory _storeAPIFactory = storeAPIFactory ?? throw new ArgumentNullException(nameof(storeAPIFactory)); + private readonly ITranslationService _translationService = translationService ?? throw new ArgumentNullException(nameof(translationService)); + private readonly IAnsiConsole _ansiConsole = ansiConsole ?? throw new ArgumentNullException(nameof(ansiConsole)); + private readonly TelemetryClient _telemetryClient = telemetryClient ?? throw new ArgumentNullException(nameof(telemetryClient)); + + public override async Task InvokeAsync(ParseResult parseResult, CancellationToken ct = default) + { + string productId = parseResult.GetRequiredValue(ReviewsCommand.ProductIdArgument); + string reviewId = parseResult.GetRequiredValue(ReviewsCommand.ReviewIdArgument); + + if (ProductTypeHelper.Solve(productId) == ProductType.Unpackaged) + { + _ansiConsole.WriteLine("This command is not supported for unpackaged applications."); + return await _telemetryClient.TrackCommandEventAsync(productId, -1, ct); + } + + var translateLanguage = parseResult.GetTranslateLanguage(); + + AppReview? review = null; + + // A failed call and a review that genuinely is not in the result set both + // leave 'review' null, so the outcome is tracked separately to avoid telling + // the user the review does not exist when the call never succeeded. + var success = await _ansiConsole.Status().StartAsync("Retrieving Review", async ctx => + { + try + { + var storePackagedAPI = await _storeAPIFactory.CreatePackagedAsync(ct: ct); + + var response = await storePackagedAPI.GetAppReviewsAsync( + productId, + parseResult.GetValue(ReviewsCommand.StartDateOption), + parseResult.GetValue(ReviewsCommand.EndDateOption), + filter: $"id eq '{reviewId.Replace("'", "''", StringComparison.Ordinal)}'", + ct: ct); + + review = response.Value?.Find(r => string.Equals(r.Id, reviewId, StringComparison.OrdinalIgnoreCase)); + + if (review != null && translateLanguage != null) + { + ctx.Status("Translating Review"); + await ReviewTranslator.TranslateAsync(_translationService, [review], translateLanguage, ct); + } + + ctx.SuccessStatus(_ansiConsole, "[bold green]Retrieved Review[/]"); + + return true; + } + catch (TranslationException err) + { + _logger.LogError(err, "Error while translating Review."); + ctx.ErrorStatus(_ansiConsole, err.Message); + return false; + } + catch (MSStoreHttpException err) + { + _logger.LogError(err, "Error while retrieving Review."); + + if (err.Response.StatusCode == System.Net.HttpStatusCode.Forbidden) + { + ctx.ErrorStatus(_ansiConsole, "Could not find the Application. Please check the ProductId."); + } + else + { + ctx.ErrorStatus(_ansiConsole, "Error while retrieving Review."); + } + + return false; + } + catch (Exception err) + { + _logger.LogError(err, "Error while retrieving Review."); + ctx.ErrorStatus(_ansiConsole, err); + return false; + } + }); + + if (!success) + { + return await _telemetryClient.TrackCommandEventAsync(productId, -1, ct); + } + + if (review == null) + { + _ansiConsole.MarkupLine(parseResult.NarrowedReviewsByDate() + ? $"Could not find review with ID '{reviewId.EscapeMarkup()}' within the requested date range. Try widening it with [bold]--startDate[/] and [bold]--endDate[/]." + : $"Could not find review with ID '{reviewId.EscapeMarkup()}'."); + + return await _telemetryClient.TrackCommandEventAsync(productId, -1, ct); + } + + StandardOutput.WriteLine(JsonSerializer.Serialize(review, SourceGenerationContext.GetCustom(true).AppReview)); + + return await _telemetryClient.TrackCommandEventAsync(productId, 0, ct); + } + } + } +} diff --git a/MSStore.CLI/Commands/Reviews/ListCommand.cs b/MSStore.CLI/Commands/Reviews/ListCommand.cs new file mode 100644 index 0000000..8a6905b --- /dev/null +++ b/MSStore.CLI/Commands/Reviews/ListCommand.cs @@ -0,0 +1,295 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.CommandLine; +using System.CommandLine.Invocation; +using System.Globalization; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.ApplicationInsights; +using Microsoft.Extensions.Logging; +using MSStore.API; +using MSStore.API.Packaged; +using MSStore.API.Packaged.Models; +using MSStore.CLI.Helpers; +using MSStore.CLI.Services; +using MSStore.CLI.Services.Translation; +using Spectre.Console; + +namespace MSStore.CLI.Commands.Reviews +{ + internal class ListCommand : Command + { + internal static readonly Option TopOption; + internal static readonly Option SkipOption; + internal static readonly Option RatingOption; + internal static readonly Option MarketOption; + + private const int MaxTextLengthInTable = 120; + + static ListCommand() + { + TopOption = new Option("--top", "-t") + { + Description = $"The maximum number of reviews to return. The Microsoft Store accepts at most {StorePackagedAPI.MaxReviewsPerRequest}." + }; + + SkipOption = new Option("--skip", "-s") + { + Description = "The number of reviews to skip, for paging through large result sets." + }; + + RatingOption = new Option("--rating") + { + Description = "Only return reviews with this star rating (1-5)." + }; + + MarketOption = new Option("--market", "-m") + { + Description = "Only return reviews from this market, as an ISO 3166 country code (for example 'US')." + }; + } + + public ListCommand() + : base("list", "List the reviews of an application.") + { + Arguments.Add(ReviewsCommand.ProductIdArgument); + Options.Add(ReviewsCommand.StartDateOption); + Options.Add(ReviewsCommand.EndDateOption); + Options.Add(TopOption); + Options.Add(SkipOption); + Options.Add(RatingOption); + Options.Add(MarketOption); + Options.Add(ReviewsCommand.TranslateOption); + } + + public class Handler( + ILogger logger, + IStoreAPIFactory storeAPIFactory, + ITranslationService translationService, + IAnsiConsole ansiConsole, + TelemetryClient telemetryClient) : AsynchronousCommandLineAction + { + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + private readonly IStoreAPIFactory _storeAPIFactory = storeAPIFactory ?? throw new ArgumentNullException(nameof(storeAPIFactory)); + private readonly ITranslationService _translationService = translationService ?? throw new ArgumentNullException(nameof(translationService)); + private readonly IAnsiConsole _ansiConsole = ansiConsole ?? throw new ArgumentNullException(nameof(ansiConsole)); + private readonly TelemetryClient _telemetryClient = telemetryClient ?? throw new ArgumentNullException(nameof(telemetryClient)); + + public override async Task InvokeAsync(ParseResult parseResult, CancellationToken ct = default) + { + string productId = parseResult.GetRequiredValue(ReviewsCommand.ProductIdArgument); + + if (ProductTypeHelper.Solve(productId) == ProductType.Unpackaged) + { + _ansiConsole.WriteLine("This command is not supported for unpackaged applications."); + return await _telemetryClient.TrackCommandEventAsync(productId, -1, ct); + } + + var top = parseResult.GetValue(TopOption); + if (top is < 1) + { + _ansiConsole.MarkupLine("[bold red]--top must be at least 1.[/]"); + return await _telemetryClient.TrackCommandEventAsync(productId, -1, ct); + } + + if (top is > StorePackagedAPI.MaxReviewsPerRequest) + { + _ansiConsole.MarkupLine($"[bold red]--top cannot be greater than {StorePackagedAPI.MaxReviewsPerRequest}.[/]"); + return await _telemetryClient.TrackCommandEventAsync(productId, -1, ct); + } + + if (parseResult.GetValue(SkipOption) is < 0) + { + _ansiConsole.MarkupLine("[bold red]--skip cannot be negative.[/]"); + return await _telemetryClient.TrackCommandEventAsync(productId, -1, ct); + } + + var rating = parseResult.GetValue(RatingOption); + if (rating is < 1 or > 5) + { + _ansiConsole.MarkupLine("[bold red]--rating must be between 1 and 5.[/]"); + return await _telemetryClient.TrackCommandEventAsync(productId, -1, ct); + } + + var translateLanguage = parseResult.GetTranslateLanguage(); + + var reviews = await _ansiConsole.Status().StartAsync("Retrieving Reviews", async ctx => + { + try + { + var storePackagedAPI = await _storeAPIFactory.CreatePackagedAsync(ct: ct); + + var response = await storePackagedAPI.GetAppReviewsAsync( + productId, + parseResult.GetValue(ReviewsCommand.StartDateOption), + parseResult.GetValue(ReviewsCommand.EndDateOption), + top, + parseResult.GetValue(SkipOption), + BuildFilter(rating, parseResult.GetValue(MarketOption)), + "date desc", + ct); + + var reviews = response.Value ?? []; + + if (translateLanguage != null && reviews.Count > 0) + { + ctx.Status("Translating Reviews"); + await ReviewTranslator.TranslateAsync(_translationService, reviews, translateLanguage, ct); + } + + ctx.SuccessStatus(_ansiConsole, "[bold green]Retrieved Reviews[/]"); + + return reviews; + } + catch (TranslationException err) + { + _logger.LogError(err, "Error while translating Reviews."); + ctx.ErrorStatus(_ansiConsole, err.Message); + return null; + } + catch (MSStoreHttpException err) + { + _logger.LogError(err, "Error while retrieving Reviews."); + + if (err.Response.StatusCode == System.Net.HttpStatusCode.Forbidden) + { + ctx.ErrorStatus(_ansiConsole, "Could not find the Application. Please check the ProductId."); + } + else + { + ctx.ErrorStatus(_ansiConsole, "Error while retrieving Reviews."); + } + + return null; + } + catch (Exception err) + { + _logger.LogError(err, "Error while retrieving Reviews."); + ctx.ErrorStatus(_ansiConsole, err); + return null; + } + }); + + if (reviews == null) + { + return await _telemetryClient.TrackCommandEventAsync(productId, -1, ct); + } + + if (reviews.Count == 0) + { + // Only refer to a period or filters when the caller actually narrowed the + // query. With no options the service returns reviews from every date, so + // implying a range was applied would be misleading. + var narrowedByDate = parseResult.NarrowedReviewsByDate(); + var narrowedByFilter = rating.HasValue || !string.IsNullOrWhiteSpace(parseResult.GetValue(MarketOption)); + + _ansiConsole.MarkupLine((narrowedByDate, narrowedByFilter) switch + { + (true, true) => "This application has [bold][u]no[/] reviews[/] matching the requested period and filters.", + (true, false) => "This application has [bold][u]no[/] reviews[/] for the requested period.", + (false, true) => "This application has [bold][u]no[/] reviews[/] matching the requested filters.", + (false, false) => "This application has [bold][u]no[/] reviews[/]." + }); + + return await _telemetryClient.TrackCommandEventAsync(productId, 0, ct); + } + + _ansiConsole.Write(BuildTable(reviews, translateLanguage)); + + return await _telemetryClient.TrackCommandEventAsync(productId, 0, ct); + } + + /// + /// Builds the OData-style filter accepted by the analytics API. String values are + /// single-quoted, and single quotes inside them are doubled. + /// + private static string? BuildFilter(int? rating, string? market) + { + var clauses = new List(); + + if (rating.HasValue) + { + clauses.Add($"rating eq {rating.Value.ToString(CultureInfo.InvariantCulture)}"); + } + + if (!string.IsNullOrWhiteSpace(market)) + { + clauses.Add($"market eq '{market.Replace("'", "''", StringComparison.Ordinal)}'"); + } + + return clauses.Count == 0 ? null : string.Join(" and ", clauses); + } + + private static Table BuildTable(IReadOnlyList reviews, string? translateLanguage) + { + var translated = translateLanguage != null; + + // The Id is shown rather than a running index because it is the value + // 'reviews get' takes, and there is no other way to discover it. + var table = new Table(); + if (translated) + { + table.AddColumns("Id", "Date", "Rating", "Market", "Lang", "Reviewer", "Title", "Review", "Reply"); + } + else + { + table.AddColumns("Id", "Date", "Rating", "Market", "Reviewer", "Title", "Review", "Reply"); + } + + foreach (var review in reviews) + { + var title = (translated ? review.TranslatedReviewTitle ?? review.ReviewTitle : review.ReviewTitle) ?? string.Empty; + var text = (translated ? review.TranslatedReviewText ?? review.ReviewText : review.ReviewText) ?? string.Empty; + + var cells = new List + { + (review.Id ?? string.Empty).EscapeMarkup(), + (review.Date ?? string.Empty).EscapeMarkup(), + FormatRating(review.Rating), + (review.Market ?? string.Empty).EscapeMarkup(), + }; + + if (translated) + { + cells.Add((review.DetectedLanguage ?? string.Empty).EscapeMarkup()); + } + + cells.Add((review.ReviewerName ?? string.Empty).EscapeMarkup()); + cells.Add(Truncate(title).EscapeMarkup()); + cells.Add(Truncate(text).EscapeMarkup()); + cells.Add(string.IsNullOrEmpty(review.ResponseText) ? string.Empty : "yes"); + + table.AddRow([.. cells]); + } + + return table; + } + + private static string FormatRating(double? rating) + { + if (!rating.HasValue) + { + return string.Empty; + } + + var stars = (int)Math.Round(rating.Value, MidpointRounding.AwayFromZero); + stars = Math.Clamp(stars, 0, 5); + + return $"{new string('*', stars)}{new string('-', 5 - stars)} ({rating.Value.ToString("0.#", CultureInfo.InvariantCulture)})"; + } + + private static string Truncate(string value) + { + // Reviews are free-form and can contain newlines, which would break the row layout. + value = value.ReplaceLineEndings(" "); + + return value.Length <= MaxTextLengthInTable + ? value + : string.Concat(value.AsSpan(0, MaxTextLengthInTable), "..."); + } + } + } +} diff --git a/MSStore.CLI/Commands/Reviews/ReviewTranslator.cs b/MSStore.CLI/Commands/Reviews/ReviewTranslator.cs new file mode 100644 index 0000000..dc2b89e --- /dev/null +++ b/MSStore.CLI/Commands/Reviews/ReviewTranslator.cs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MSStore.API.Packaged.Models; +using MSStore.CLI.Services.Translation; + +namespace MSStore.CLI.Commands.Reviews +{ + internal static class ReviewTranslator + { + /// + /// Populates the translated fields of each review in place. + /// + /// + /// Titles and texts are sent in a single call so the service batches them together + /// rather than paying the per-request overhead twice. + /// + /// The translation service to use. + /// The reviews to translate, modified in place. + /// The language to translate into. + /// Cancellation token. + /// A task that completes once every review has been updated. + public static async Task TranslateAsync(ITranslationService translationService, IReadOnlyList reviews, string targetLanguage, CancellationToken ct) + { + var language = await translationService.ResolveLanguageAsync(targetLanguage, ct); + + var texts = new List(reviews.Count * 2); + foreach (var review in reviews) + { + texts.Add(review.ReviewTitle); + texts.Add(review.ReviewText); + } + + var translations = await translationService.TranslateAsync(texts, language, ct); + + for (var i = 0; i < reviews.Count; i++) + { + var title = translations[i * 2]; + var text = translations[(i * 2) + 1]; + + reviews[i].TranslatedReviewTitle = title?.Text; + reviews[i].TranslatedReviewText = text?.Text; + + // The body is the better signal for the review's language; fall back to the + // title when the body was empty. + reviews[i].DetectedLanguage = text?.DetectedLanguage ?? title?.DetectedLanguage; + } + } + } +} diff --git a/MSStore.CLI/Commands/ReviewsCommand.cs b/MSStore.CLI/Commands/ReviewsCommand.cs new file mode 100644 index 0000000..b788aca --- /dev/null +++ b/MSStore.CLI/Commands/ReviewsCommand.cs @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.CommandLine; +using MSStore.CLI.Commands.Reviews; + +namespace MSStore.CLI.Commands +{ + internal class ReviewsCommand : Command + { + /// + /// The language used when --translate is passed without a value. + /// + internal const string DefaultTranslateLanguage = "en"; + + internal static readonly Argument ProductIdArgument; + internal static readonly Argument ReviewIdArgument; + internal static readonly Option StartDateOption; + internal static readonly Option EndDateOption; + internal static readonly Option TranslateOption; + + static ReviewsCommand() + { + ProductIdArgument = new Argument("productId") + { + Description = "The product ID." + }; + + ReviewIdArgument = new Argument("reviewId") + { + Description = "The review ID." + }; + + StartDateOption = new Option("--startDate") + { + Description = "Only return reviews submitted on or after this date (yyyy-MM-dd). If omitted, reviews from all dates are returned." + }; + + EndDateOption = new Option("--endDate") + { + Description = "Only return reviews submitted on or before this date (yyyy-MM-dd). If omitted, reviews from all dates are returned." + }; + + TranslateOption = new Option("--translate") + { + Description = $"Translate the review title and text into this language, using Azure AI Translator. Defaults to '{DefaultTranslateLanguage}' when no language is given. The Microsoft Store provides no translation of its own, so this requires your own Translator key.", + Arity = ArgumentArity.ZeroOrOne + }; + } + + public ReviewsCommand(ListCommand listCommand, GetCommand getCommand) + : base("reviews", "Execute reviews related tasks.") + { + Subcommands.Add(listCommand); + Subcommands.Add(getCommand); + } + } +} diff --git a/MSStore.CLI/Commands/Settings/SetTranslatorKeyCommand.cs b/MSStore.CLI/Commands/Settings/SetTranslatorKeyCommand.cs new file mode 100644 index 0000000..a71f604 --- /dev/null +++ b/MSStore.CLI/Commands/Settings/SetTranslatorKeyCommand.cs @@ -0,0 +1,120 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.CommandLine; +using System.CommandLine.Invocation; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.ApplicationInsights; +using Microsoft.Extensions.Logging; +using MSStore.CLI.Helpers; +using MSStore.CLI.Services; +using MSStore.CLI.Services.CredentialManager; +using MSStore.CLI.Services.Translation; +using Spectre.Console; + +namespace MSStore.CLI.Commands.Settings +{ + internal class SetTranslatorKeyCommand : Command + { + internal static readonly Argument KeyArgument; + internal static readonly Option RegionOption; + internal static readonly Option ClearOption; + + static SetTranslatorKeyCommand() + { + KeyArgument = new Argument("key") + { + Description = "The Azure AI Translator resource key. Used by the --translate option of the reviews commands.", + Arity = ArgumentArity.ZeroOrOne + }; + + RegionOption = new Option("--region", "-r") + { + Description = "The region of the Azure AI Translator resource. Required for regional and multi-service resources, and unnecessary for a global one." + }; + + ClearOption = new Option("--clear") + { + DefaultValueFactory = _ => false, + Description = "Remove the stored Azure AI Translator key and region." + }; + } + + public SetTranslatorKeyCommand() + : base("set-translator-key", "Store the Azure AI Translator key used by the reviews '--translate' option.") + { + Arguments.Add(KeyArgument); + Options.Add(RegionOption); + Options.Add(ClearOption); + } + + public class Handler( + ILogger logger, + ICredentialManager credentialManager, + IConfigurationManager configurationManager, + IAnsiConsole ansiConsole, + TelemetryClient telemetryClient) : AsynchronousCommandLineAction + { + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + private readonly ICredentialManager _credentialManager = credentialManager ?? throw new ArgumentNullException(nameof(credentialManager)); + private readonly IConfigurationManager _configurationManager = configurationManager ?? throw new ArgumentNullException(nameof(configurationManager)); + private readonly IAnsiConsole _ansiConsole = ansiConsole ?? throw new ArgumentNullException(nameof(ansiConsole)); + private readonly TelemetryClient _telemetryClient = telemetryClient ?? throw new ArgumentNullException(nameof(telemetryClient)); + + public override async Task InvokeAsync(ParseResult parseResult, CancellationToken ct = default) + { + var key = parseResult.GetValue(KeyArgument); + var region = parseResult.GetValue(RegionOption); + var clear = parseResult.GetValue(ClearOption); + + try + { + var config = await _configurationManager.LoadAsync(ct: ct); + + if (clear) + { + _credentialManager.ClearCredentials(AzureAITranslatorService.CredentialKeyName); + config.TranslatorRegion = null; + await _configurationManager.SaveAsync(config, ct); + + _ansiConsole.MarkupLine("Azure AI Translator key and region [bold green]cleared[/]."); + return await _telemetryClient.TrackCommandEventAsync(0, ct); + } + + if (string.IsNullOrWhiteSpace(key)) + { + _ansiConsole.MarkupLine("[bold red]A key is required.[/] Pass the Azure AI Translator resource key, or use [bold]--clear[/] to remove the stored one."); + return await _telemetryClient.TrackCommandEventAsync(-1, ct); + } + + // Keys and regions are frequently pasted or piped in with surrounding + // whitespace, which is not valid in a request header. + _credentialManager.WriteCredential(AzureAITranslatorService.CredentialKeyName, key.Trim()); + + if (!string.IsNullOrWhiteSpace(region)) + { + config.TranslatorRegion = region.Trim(); + await _configurationManager.SaveAsync(config, ct); + } + + _ansiConsole.MarkupLine("Azure AI Translator key [bold green]stored[/]."); + + if (string.IsNullOrWhiteSpace(region) && string.IsNullOrWhiteSpace(config.TranslatorRegion)) + { + _ansiConsole.MarkupLine("No region is set. That is correct for a [bold]global[/] Translator resource, but regional and multi-service resources need one - re-run with [bold]--region[/]."); + } + + return await _telemetryClient.TrackCommandEventAsync(0, ct); + } + catch (Exception err) + { + _logger.LogError(err, "Error while storing the Azure AI Translator key."); + _ansiConsole.MarkupLine("[bold red]Could not store the Azure AI Translator key.[/]"); + return await _telemetryClient.TrackCommandEventAsync(-1, ct); + } + } + } + } +} diff --git a/MSStore.CLI/Commands/SettingsCommand.cs b/MSStore.CLI/Commands/SettingsCommand.cs index 7d22054..a9effa3 100644 --- a/MSStore.CLI/Commands/SettingsCommand.cs +++ b/MSStore.CLI/Commands/SettingsCommand.cs @@ -28,12 +28,13 @@ static SettingsCommand() }; } - public SettingsCommand(SetPublisherDisplayNameCommand setPublisherDisplayNameCommand) + public SettingsCommand(SetPublisherDisplayNameCommand setPublisherDisplayNameCommand, SetTranslatorKeyCommand setTranslatorKeyCommand) : base("settings", "Change settings of the Microsoft Store Developer CLI.") { Options.Add(EnableTelemetryOption); Subcommands.Add(setPublisherDisplayNameCommand); + Subcommands.Add(setTranslatorKeyCommand); } public class Handler(TelemetryClient telemetryClient, IConfigurationManager telemetryConfigurationManager, IConfigurationManager configurationManager, ILogger logger) : AsynchronousCommandLineAction diff --git a/MSStore.CLI/Helpers/ParseResultExtensions.cs b/MSStore.CLI/Helpers/ParseResultExtensions.cs index 70b2635..c8ac097 100644 --- a/MSStore.CLI/Helpers/ParseResultExtensions.cs +++ b/MSStore.CLI/Helpers/ParseResultExtensions.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System.CommandLine; +using MSStore.CLI.Commands; namespace MSStore.CLI.Helpers { @@ -12,5 +13,39 @@ public static bool IsVerbose(this ParseResult parseResult) return parseResult.RootCommandResult.Command is MicrosoftStoreCLI storeCLI && parseResult.GetValue(MicrosoftStoreCLI.VerboseOption); } + + /// + /// Resolves the language requested through --translate, or null when the option was + /// not supplied. Passing the option without a value selects the default language. + /// + /// The parsed command line. + /// The requested language, or null when --translate was not supplied. + public static string? GetTranslateLanguage(this ParseResult parseResult) + { + if (parseResult.GetResult(ReviewsCommand.TranslateOption) == null) + { + return null; + } + + var language = parseResult.GetValue(ReviewsCommand.TranslateOption); + + return string.IsNullOrWhiteSpace(language) ? ReviewsCommand.DefaultTranslateLanguage : language; + } + + /// + /// Indicates whether the caller narrowed the reviews query by date. + /// + /// The parsed command line. + /// True when --startDate or --endDate was supplied. + /// + /// Leaving both options off sends no date parameters at all, and the service then + /// returns reviews from every date, so messages must not imply a date range was + /// applied in that case. + /// + public static bool NarrowedReviewsByDate(this ParseResult parseResult) + { + return parseResult.GetResult(ReviewsCommand.StartDateOption) != null + || parseResult.GetResult(ReviewsCommand.EndDateOption) != null; + } } } diff --git a/MSStore.CLI/MicrosoftStoreCLI.cs b/MSStore.CLI/MicrosoftStoreCLI.cs index 1efbdbf..60f9039 100644 --- a/MSStore.CLI/MicrosoftStoreCLI.cs +++ b/MSStore.CLI/MicrosoftStoreCLI.cs @@ -39,7 +39,7 @@ internal static void WelcomeMessage(IAnsiConsole ansiConsole) ansiConsole.WriteLine(); } - public MicrosoftStoreCLI(InfoCommand infoCommand, ReconfigureCommand reconfigureCommand, SettingsCommand settingsCommand, AppsCommand appsCommand, SubmissionCommand submissionCommand, FlightsCommand flightsCommand, InitCommand initCommand, PackageCommand packageCommand, PublishCommand publishCommand, Handler handler) + public MicrosoftStoreCLI(InfoCommand infoCommand, ReconfigureCommand reconfigureCommand, SettingsCommand settingsCommand, AppsCommand appsCommand, SubmissionCommand submissionCommand, FlightsCommand flightsCommand, ReviewsCommand reviewsCommand, InitCommand initCommand, PackageCommand packageCommand, PublishCommand publishCommand, Handler handler) : base(description: "CLI tool to automate Microsoft Store Developer tasks.") { Subcommands.Add(infoCommand); @@ -48,6 +48,7 @@ public MicrosoftStoreCLI(InfoCommand infoCommand, ReconfigureCommand reconfigure Subcommands.Add(appsCommand); Subcommands.Add(submissionCommand); Subcommands.Add(flightsCommand); + Subcommands.Add(reviewsCommand); Subcommands.Add(initCommand); Subcommands.Add(packageCommand); Subcommands.Add(publishCommand); diff --git a/MSStore.CLI/Program.cs b/MSStore.CLI/Program.cs index d969440..a087d07 100644 --- a/MSStore.CLI/Program.cs +++ b/MSStore.CLI/Program.cs @@ -28,6 +28,7 @@ using MSStore.CLI.Services.PWABuilder; using MSStore.CLI.Services.Telemetry; using MSStore.CLI.Services.TokenManager; +using MSStore.CLI.Services.Translation; using OpenTelemetry; using OpenTelemetry.Logs; using OpenTelemetry.Resources; @@ -110,6 +111,7 @@ public static async Task Main(params string[] args) .AddScoped() .AddScoped() .AddScoped() + .AddScoped() .AddSingleton() .AddSingleton(telemetryClient); @@ -167,6 +169,22 @@ public static async Task Main(params string[] args) }; }); + services + .AddHttpClient(nameof(AzureAITranslatorService), client => + { + client.BaseAddress = new Uri("https://api.cognitive.microsofttranslator.com"); + }) + .ConfigurePrimaryHttpMessageHandler(() => + { + // Deliberately not RetryAfterHttpHandler: it retries 429s forever + // with no attempt cap, and Translator does not document a + // Retry-After header. AzureAITranslatorService backs off itself. + return new HttpClientHandler + { + CheckCertificateRevocationList = true + }; + }); + void AddPWABuilderDefaultHeaders(HttpRequestHeaders defaultRequestHeaders) { defaultRequestHeaders.Add("Platform-Identifier", "MSStoreCLI"); diff --git a/MSStore.CLI/Services/Configurations.cs b/MSStore.CLI/Services/Configurations.cs index 5975449..945945e 100644 --- a/MSStore.CLI/Services/Configurations.cs +++ b/MSStore.CLI/Services/Configurations.cs @@ -29,6 +29,14 @@ internal class Configurations [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? PublisherDisplayName { get; set; } + /// + /// Gets or sets the region of the Azure AI Translator resource. Required for regional + /// and multi-service resources, and unnecessary for a global one. This is not a + /// secret; the key itself lives in the OS secure store. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? TranslatorRegion { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] public bool ClientAssertion { get; set; } diff --git a/MSStore.CLI/Services/EnvironmentInformationService.cs b/MSStore.CLI/Services/EnvironmentInformationService.cs index 791bcfc..8ef85ca 100644 --- a/MSStore.CLI/Services/EnvironmentInformationService.cs +++ b/MSStore.CLI/Services/EnvironmentInformationService.cs @@ -39,5 +39,7 @@ public EnvironmentInformationService(ILogger logg } public bool IsRunningOnCI => _runningOnCI; + + public string? GetEnvironmentVariable(string name) => Environment.GetEnvironmentVariable(name); } } diff --git a/MSStore.CLI/Services/IEnvironmentInformationService.cs b/MSStore.CLI/Services/IEnvironmentInformationService.cs index 5c91c15..bf486ef 100644 --- a/MSStore.CLI/Services/IEnvironmentInformationService.cs +++ b/MSStore.CLI/Services/IEnvironmentInformationService.cs @@ -6,5 +6,14 @@ namespace MSStore.CLI.Services internal interface IEnvironmentInformationService { bool IsRunningOnCI { get; } + + /// + /// Reads an environment variable. Going through this service rather than + /// directly keeps consumers testable without + /// mutating process-wide state. + /// + /// The name of the environment variable. + /// The value, or null when the variable is not set. + string? GetEnvironmentVariable(string name); } } diff --git a/MSStore.CLI/Services/Translation/AzureAITranslatorService.cs b/MSStore.CLI/Services/Translation/AzureAITranslatorService.cs new file mode 100644 index 0000000..1dee936 --- /dev/null +++ b/MSStore.CLI/Services/Translation/AzureAITranslatorService.cs @@ -0,0 +1,394 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using MSStore.CLI.Services.CredentialManager; +using MSStore.CLI.Services.Translation.Models; + +namespace MSStore.CLI.Services.Translation +{ + /// + /// Translates text with the Azure AI Translator Text API. + /// + /// + /// This targets API version 3.0. Version 2026-06-06 is the newer GA release but is not + /// backward compatible: it nests the request under inputs, returns results under + /// value, moves target languages into the body, and its headline features expect + /// a Microsoft Foundry resource. Version 3.0 works with a plain Translator resource key, + /// which is what users supply here. The version and the wire DTOs are deliberately + /// isolated so moving to a newer version stays contained to this class and its models. + /// + internal class AzureAITranslatorService( + IHttpClientFactory httpClientFactory, + ICredentialManager credentialManager, + IConfigurationManager configurationManager, + IEnvironmentInformationService environmentInformationService, + ILogger logger) : ITranslationService + { + /// + /// The name the Translator key is stored under in the OS secure store. + /// + internal const string CredentialKeyName = "AzureAITranslator"; + + internal const string KeyEnvironmentVariable = "MSSTORE_TRANSLATOR_KEY"; + internal const string RegionEnvironmentVariable = "MSSTORE_TRANSLATOR_REGION"; + + internal const string ApiVersion = "3.0"; + + /// + /// The documented maximum number of elements in one translate request. + /// + internal const int MaxElementsPerRequest = 1000; + + /// + /// The documented maximum number of characters in one translate request, counted + /// across all target languages. Only one target is ever requested here. + /// + internal const int MaxCharactersPerRequest = 50000; + + private const int MaxRetryAttempts = 3; + + private readonly IHttpClientFactory _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); + private readonly ICredentialManager _credentialManager = credentialManager ?? throw new ArgumentNullException(nameof(credentialManager)); + private readonly IConfigurationManager _configurationManager = configurationManager ?? throw new ArgumentNullException(nameof(configurationManager)); + private readonly IEnvironmentInformationService _environmentInformationService = environmentInformationService ?? throw new ArgumentNullException(nameof(environmentInformationService)); + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + private Dictionary? _cachedLanguages; + + public async Task ResolveLanguageAsync(string language, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(language)) + { + throw new TranslationException("No target language was provided for translation."); + } + + language = language.Trim(); + + var languages = await GetSupportedLanguagesAsync(ct); + if (languages == null) + { + // The language list is only used to canonicalize and validate. If it is + // unavailable, let the service itself reject an invalid code. + return language; + } + + var match = languages.Keys.FirstOrDefault(k => string.Equals(k, language, StringComparison.OrdinalIgnoreCase)); + if (match != null) + { + return match; + } + + throw new TranslationException($"'{language}' is not a language supported by Azure AI Translator. See https://learn.microsoft.com/azure/ai-services/translator/language-support for the list of supported codes."); + } + + public async Task> TranslateAsync(IReadOnlyList texts, string targetLanguage, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(texts); + + var results = new TranslationResult?[texts.Count]; + + // Only non-empty entries are billable and worth sending. Track the original + // index of each so results line up with the caller's list. + var pending = new List(); + for (var i = 0; i < texts.Count; i++) + { + if (!string.IsNullOrWhiteSpace(texts[i])) + { + pending.Add(i); + } + } + + if (pending.Count == 0) + { + return results; + } + + var key = GetKey(); + if (string.IsNullOrEmpty(key)) + { + throw new TranslationException( + $"No Azure AI Translator key is configured. Set the {KeyEnvironmentVariable} environment variable, or store one with 'msstore settings set-translator-key'."); + } + + var region = await GetRegionAsync(ct); + + foreach (var batch in CreateBatches(texts, pending)) + { + var translated = await TranslateBatchAsync([.. batch.Select(i => texts[i]!)], targetLanguage, key, region, ct); + + for (var i = 0; i < batch.Count; i++) + { + results[batch[i]] = i < translated.Count ? translated[i] : null; + } + } + + return results; + } + + /// + /// Splits the indexes to translate into batches that respect both documented limits. + /// A single item longer than the per-request character limit is sent on its own so + /// the service can report the specific error rather than the batch failing opaquely. + /// + /// The full list of texts being translated. + /// The indexes of the entries that need translating. + /// The indexes grouped into batches. + private static List> CreateBatches(IReadOnlyList texts, List indexes) + { + var batches = new List>(); + var current = new List(); + var currentLength = 0; + + foreach (var index in indexes) + { + var length = texts[index]!.Length; + + if (current.Count > 0 && + (current.Count >= MaxElementsPerRequest || currentLength + length > MaxCharactersPerRequest)) + { + batches.Add(current); + current = []; + currentLength = 0; + } + + current.Add(index); + currentLength += length; + } + + if (current.Count > 0) + { + batches.Add(current); + } + + return batches; + } + + private static bool IsTransient(HttpStatusCode statusCode) => + statusCode == HttpStatusCode.TooManyRequests || + statusCode == HttpStatusCode.RequestTimeout || + statusCode >= HttpStatusCode.InternalServerError; + + /// + /// Translator does not document a Retry-After header on 429, so exponential backoff + /// with jitter is the primary strategy and the header is only honoured if present. + /// + /// The throttled or failed response. + /// The 1-based attempt number that just failed. + /// How long to wait before retrying. + private static TimeSpan GetRetryDelay(HttpResponseMessage response, int attempt) + { + var retryAfter = response.Headers.RetryAfter; + if (retryAfter?.Delta is TimeSpan delta && delta > TimeSpan.Zero) + { + return delta; + } + + if (retryAfter?.Date is DateTimeOffset date) + { + var until = date - DateTimeOffset.UtcNow; + if (until > TimeSpan.Zero) + { + return until; + } + } + + var backoff = TimeSpan.FromSeconds(Math.Pow(2, attempt)); + return backoff + TimeSpan.FromMilliseconds(Random.Shared.Next(0, 500)); + } + + /// + /// Reads the Translator key, preferring the environment variable over the stored one. + /// + /// + /// Values are trimmed because a key pasted or piped in often carries trailing + /// whitespace or a newline, and header values cannot contain either. An untrimmed + /// newline makes the request throw before it is ever sent, which surfaces as an + /// opaque failure rather than an authentication message. + /// + /// The key, or null when none is configured. + private string? GetKey() + { + var fromEnvironment = _environmentInformationService.GetEnvironmentVariable(KeyEnvironmentVariable)?.Trim(); + if (!string.IsNullOrEmpty(fromEnvironment)) + { + return fromEnvironment; + } + + var stored = _credentialManager.ReadCredential(CredentialKeyName)?.Trim(); + return string.IsNullOrEmpty(stored) ? null : stored; + } + + private async Task GetRegionAsync(CancellationToken ct) + { + var fromEnvironment = _environmentInformationService.GetEnvironmentVariable(RegionEnvironmentVariable)?.Trim(); + if (!string.IsNullOrEmpty(fromEnvironment)) + { + return fromEnvironment; + } + + var config = await _configurationManager.LoadAsync(ct: ct); + var region = config.TranslatorRegion?.Trim(); + return string.IsNullOrEmpty(region) ? null : region; + } + + private async Task?> GetSupportedLanguagesAsync(CancellationToken ct) + { + if (_cachedLanguages != null) + { + return _cachedLanguages; + } + + try + { + using var httpClient = _httpClientFactory.CreateClient(nameof(AzureAITranslatorService)); + + // Listing languages requires no authentication. + using var response = await httpClient.GetAsync($"/languages?api-version={ApiVersion}&scope=translation", ct); + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("Could not retrieve the list of supported languages: {StatusCode}.", response.StatusCode); + return null; + } + + var languages = JsonSerializer.Deserialize( + await response.Content.ReadAsStringAsync(ct), + TranslationSourceGenerationContext.GetCustom().TranslatorLanguagesResponse); + + _cachedLanguages = languages?.Translation; + return _cachedLanguages; + } + catch (Exception err) when (err is not OperationCanceledException) + { + _logger.LogWarning(err, "Could not retrieve the list of supported languages."); + return null; + } + } + + private async Task> TranslateBatchAsync(IReadOnlyList texts, string targetLanguage, string key, string? region, CancellationToken ct) + { + var body = JsonSerializer.Serialize( + texts.Select(t => new TranslateInput { Text = t }).ToList(), + TranslationSourceGenerationContext.GetCustom().ListTranslateInput); + + // The 'from' parameter is deliberately omitted so the service auto-detects the + // source language inline. A separate /detect call would be metered separately. + var route = $"/translate?api-version={ApiVersion}&to={Uri.EscapeDataString(targetLanguage)}"; + + using var httpClient = _httpClientFactory.CreateClient(nameof(AzureAITranslatorService)); + + for (var attempt = 1; ; attempt++) + { + using var request = new HttpRequestMessage(HttpMethod.Post, route) + { + Content = new StringContent(body, Encoding.UTF8, "application/json") + }; + + request.Headers.Add("Ocp-Apim-Subscription-Key", key); + + // Required for regional and multi-service resources, optional for a global + // resource. Sending it when present is harmless; omitting it when needed + // produces a 401. + if (!string.IsNullOrEmpty(region)) + { + request.Headers.Add("Ocp-Apim-Subscription-Region", region); + } + + using var response = await httpClient.SendAsync(request, ct); + + if (response.IsSuccessStatusCode) + { + if (response.Headers.TryGetValues("X-metered-usage", out var usage)) + { + _logger.LogInformation("Translator billed characters for this request: {MeteredUsage}.", string.Join(',', usage)); + } + + var items = JsonSerializer.Deserialize( + await response.Content.ReadAsStringAsync(ct), + TranslationSourceGenerationContext.GetCustom().ListTranslateResultItem); + + if (items == null) + { + throw new TranslationException("The translation service returned an unreadable response."); + } + + return [.. items.Select(item => + { + var text = item.Translations?.FirstOrDefault()?.Text; + return text == null ? null : new TranslationResult(text, item.DetectedLanguage?.Language); + })]; + } + + if (IsTransient(response.StatusCode) && attempt < MaxRetryAttempts) + { + await Task.Delay(GetRetryDelay(response, attempt), ct); + continue; + } + + throw await CreateExceptionAsync(response, ct); + } + } + + private async Task CreateExceptionAsync(HttpResponseMessage response, CancellationToken ct) + { + var content = await response.Content.ReadAsStringAsync(ct); + + TranslatorError? error = null; + try + { + error = JsonSerializer.Deserialize(content, TranslationSourceGenerationContext.GetCustom().TranslatorErrorResponse)?.Error; + } + catch (JsonException) + { + // Fall through to the generic message below. + } + + var requestId = response.Headers.TryGetValues("X-RequestId", out var requestIds) + ? string.Join(',', requestIds) + : "(none)"; + + // The status, service error code and request id are enough to diagnose a failure + // and to raise a support case. The body is logged only at debug level because a + // failed request can echo back the submitted review text, which should not end up + // in CI logs collected at the default level. + _logger.LogError( + "Translator request failed with {StatusCode}. Service error code: {ErrorCode}. X-RequestId: {RequestId}.", + (int)response.StatusCode, + error?.Code, + requestId); + + _logger.LogDebug("Translator error response body: {Body}", content); + + var message = error?.Code switch + { + 401015 => "The key provided is for the Speech API, but the Text Translation API is required. Check that you copied the key from a Translator resource.", + >= 401000 and < 402000 => $"Azure AI Translator rejected the credentials. Check the {KeyEnvironmentVariable} value, and set {RegionEnvironmentVariable} if your Translator resource is regional or multi-service rather than global.", + 403001 => "The Azure AI Translator subscription has exceeded its free quota.", + >= 403000 and < 404000 => "Azure AI Translator refused the operation. This usually means the resource region is wrong or missing.", + >= 429000 and < 430000 => "Azure AI Translator is throttling this request. The free tier allows 2 million characters per hour, consumed evenly, so a large burst of reviews can be rejected. Try a smaller --top value.", + 400019 or 400036 => "Azure AI Translator does not support the requested target language.", + 400050 => "A review is longer than the maximum length Azure AI Translator accepts.", + _ => null + }; + + if (message != null) + { + return new TranslationException(message); + } + + return new TranslationException( + error?.Message is { Length: > 0 } serviceMessage + ? $"Azure AI Translator returned an error: {serviceMessage}" + : $"Azure AI Translator returned {(int)response.StatusCode} {response.ReasonPhrase}."); + } + } +} diff --git a/MSStore.CLI/Services/Translation/ITranslationService.cs b/MSStore.CLI/Services/Translation/ITranslationService.cs new file mode 100644 index 0000000..f87230c --- /dev/null +++ b/MSStore.CLI/Services/Translation/ITranslationService.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace MSStore.CLI.Services.Translation +{ + /// + /// Translates text into a target language. + /// + /// + /// The Microsoft Store APIs return no translated text and no language metadata for + /// reviews, so translation has to come from a separate service. This interface keeps + /// the provider and its wire format out of the commands. + /// + internal interface ITranslationService + { + /// + /// Resolves a user-supplied language code to the canonical code used by the service. + /// + /// The language code supplied by the user. + /// Cancellation token. + /// + /// The canonical language code, or unchanged when the + /// supported-language list could not be retrieved. + /// + Task ResolveLanguageAsync(string language, CancellationToken ct = default); + + /// + /// Translates each entry of into . + /// + /// The texts to translate. + /// The canonical target language code. + /// Cancellation token. + /// + /// One entry per input, in the same order. Entries whose input was null or + /// whitespace are null, and were never sent to the service. + /// + Task> TranslateAsync(IReadOnlyList texts, string targetLanguage, CancellationToken ct = default); + } +} diff --git a/MSStore.CLI/Services/Translation/Models/DetectedLanguageInfo.cs b/MSStore.CLI/Services/Translation/Models/DetectedLanguageInfo.cs new file mode 100644 index 0000000..9573655 --- /dev/null +++ b/MSStore.CLI/Services/Translation/Models/DetectedLanguageInfo.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +namespace MSStore.CLI.Services.Translation.Models +{ + internal class DetectedLanguageInfo + { + public string? Language { get; set; } + + /// + /// Gets or sets the confidence of the detection, between 0.0 and 1.0. The published + /// Swagger types this as an integer, which is wrong; the service returns a float. + /// + public double Score { get; set; } + } +} diff --git a/MSStore.CLI/Services/Translation/Models/TranslateInput.cs b/MSStore.CLI/Services/Translation/Models/TranslateInput.cs new file mode 100644 index 0000000..15eadea --- /dev/null +++ b/MSStore.CLI/Services/Translation/Models/TranslateInput.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace MSStore.CLI.Services.Translation.Models +{ + /// + /// A single element of the Translator v3.0 request body, which is a bare JSON array. + /// + internal class TranslateInput + { + /// + /// Gets or sets the text to translate. + /// + /// + /// The wire name is pinned rather than left to the serializer's naming policy. The + /// reference documentation is inconsistent: the request body section states the + /// property is named Text and the curl examples and official C# quickstart + /// send that, while the JSON sample in the same section and the published Swagger use + /// text. The service accepts either, so this follows the normative prose and, + /// more importantly, no longer changes if the shared naming policy is ever altered. + /// + [JsonPropertyName("Text")] + public string? Text { get; set; } + } +} diff --git a/MSStore.CLI/Services/Translation/Models/TranslateResultItem.cs b/MSStore.CLI/Services/Translation/Models/TranslateResultItem.cs new file mode 100644 index 0000000..5cb0a55 --- /dev/null +++ b/MSStore.CLI/Services/Translation/Models/TranslateResultItem.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Collections.Generic; + +namespace MSStore.CLI.Services.Translation.Models +{ + /// + /// A single element of the Translator v3.0 response body, which is a bare JSON array + /// with one element per input, in the same order. + /// + internal class TranslateResultItem + { + /// + /// Gets or sets the detected source language. Only present when the request omitted + /// the from parameter, which is how automatic detection is requested. + /// + public DetectedLanguageInfo? DetectedLanguage { get; set; } + + public List? Translations { get; set; } + } +} diff --git a/MSStore.CLI/Services/Translation/Models/TranslationInfo.cs b/MSStore.CLI/Services/Translation/Models/TranslationInfo.cs new file mode 100644 index 0000000..afc23a3 --- /dev/null +++ b/MSStore.CLI/Services/Translation/Models/TranslationInfo.cs @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +namespace MSStore.CLI.Services.Translation.Models +{ + internal class TranslationInfo + { + public string? Text { get; set; } + public string? To { get; set; } + } +} diff --git a/MSStore.CLI/Services/Translation/Models/TranslatorError.cs b/MSStore.CLI/Services/Translation/Models/TranslatorError.cs new file mode 100644 index 0000000..7d27f6f --- /dev/null +++ b/MSStore.CLI/Services/Translation/Models/TranslatorError.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +namespace MSStore.CLI.Services.Translation.Models +{ + internal class TranslatorError + { + /// + /// Gets or sets the six-digit error code, made of the three-digit HTTP status + /// followed by a three-digit sub-code. The service returns this as a JSON number, + /// not a string, despite what the published Swagger says. + /// + public int Code { get; set; } + + public string? Message { get; set; } + } +} diff --git a/MSStore.CLI/Services/Translation/Models/TranslatorErrorResponse.cs b/MSStore.CLI/Services/Translation/Models/TranslatorErrorResponse.cs new file mode 100644 index 0000000..6a4d6ac --- /dev/null +++ b/MSStore.CLI/Services/Translation/Models/TranslatorErrorResponse.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +namespace MSStore.CLI.Services.Translation.Models +{ + internal class TranslatorErrorResponse + { + public TranslatorError? Error { get; set; } + } +} diff --git a/MSStore.CLI/Services/Translation/Models/TranslatorLanguage.cs b/MSStore.CLI/Services/Translation/Models/TranslatorLanguage.cs new file mode 100644 index 0000000..17a9877 --- /dev/null +++ b/MSStore.CLI/Services/Translation/Models/TranslatorLanguage.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +namespace MSStore.CLI.Services.Translation.Models +{ + internal class TranslatorLanguage + { + public string? Name { get; set; } + public string? NativeName { get; set; } + public string? Dir { get; set; } + } +} diff --git a/MSStore.CLI/Services/Translation/Models/TranslatorLanguagesResponse.cs b/MSStore.CLI/Services/Translation/Models/TranslatorLanguagesResponse.cs new file mode 100644 index 0000000..253a4e1 --- /dev/null +++ b/MSStore.CLI/Services/Translation/Models/TranslatorLanguagesResponse.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Collections.Generic; + +namespace MSStore.CLI.Services.Translation.Models +{ + /// + /// The response of GET /languages?api-version=3.0&scope=translation, which + /// requires no authentication. + /// + internal class TranslatorLanguagesResponse + { + /// + /// Gets or sets the supported languages, keyed by BCP-47 tag. + /// + public Dictionary? Translation { get; set; } + } +} diff --git a/MSStore.CLI/Services/Translation/TranslationException.cs b/MSStore.CLI/Services/Translation/TranslationException.cs new file mode 100644 index 0000000..d589f2f --- /dev/null +++ b/MSStore.CLI/Services/Translation/TranslationException.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; + +namespace MSStore.CLI.Services.Translation +{ + /// + /// Raised when the translation service cannot fulfil a request. The message is intended + /// to be shown to the user directly. + /// + internal class TranslationException : Exception + { + public TranslationException() + { + } + + public TranslationException(string message) + : base(message) + { + } + + public TranslationException(string message, Exception innerException) + : base(message, innerException) + { + } + } +} diff --git a/MSStore.CLI/Services/Translation/TranslationResult.cs b/MSStore.CLI/Services/Translation/TranslationResult.cs new file mode 100644 index 0000000..2c7ad8c --- /dev/null +++ b/MSStore.CLI/Services/Translation/TranslationResult.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +namespace MSStore.CLI.Services.Translation +{ + /// + /// The translation of a single piece of text. + /// + internal class TranslationResult(string text, string? detectedLanguage) + { + /// + /// Gets the translated text. + /// + public string Text { get; } = text; + + /// + /// Gets the language detected in the source text, or null when the service did not + /// report one. + /// + public string? DetectedLanguage { get; } = detectedLanguage; + } +} diff --git a/MSStore.CLI/Services/Translation/TranslationSourceGenerationContext.cs b/MSStore.CLI/Services/Translation/TranslationSourceGenerationContext.cs new file mode 100644 index 0000000..2a3fcb7 --- /dev/null +++ b/MSStore.CLI/Services/Translation/TranslationSourceGenerationContext.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; +using MSStore.CLI.Services.Translation.Models; + +namespace MSStore.CLI.Services.Translation +{ + /// + /// Source Generator Configuration for JSON Serialization/Deserialization of + /// Azure AI Translator calls. + /// + [JsonSerializable(typeof(List))] + [JsonSerializable(typeof(List))] + [JsonSerializable(typeof(TranslatorErrorResponse))] + [JsonSerializable(typeof(TranslatorLanguagesResponse))] + internal partial class TranslationSourceGenerationContext : JsonSerializerContext + { + private static TranslationSourceGenerationContext? _default; + + public static TranslationSourceGenerationContext GetCustom() + { + return _default ??= new TranslationSourceGenerationContext(new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.Never, + PropertyNameCaseInsensitive = true, + IgnoreReadOnlyFields = false, + IgnoreReadOnlyProperties = false, + IncludeFields = false + }); + } + } +} diff --git a/MSStore.CLI/StoreHostBuilderExtensions.cs b/MSStore.CLI/StoreHostBuilderExtensions.cs index 8670b3f..8972c71 100644 --- a/MSStore.CLI/StoreHostBuilderExtensions.cs +++ b/MSStore.CLI/StoreHostBuilderExtensions.cs @@ -17,6 +17,7 @@ internal static class StoreHostBuilderExtensions [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ReconfigureCommand.Handler))] [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(SettingsCommand.Handler))] [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Commands.Settings.SetPublisherDisplayNameCommand.Handler))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Commands.Settings.SetTranslatorKeyCommand.Handler))] [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Commands.Apps.ListCommand.Handler))] [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Commands.Apps.GetCommand.Handler))] [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Commands.Submission.StatusCommand.Handler))] @@ -45,6 +46,8 @@ internal static class StoreHostBuilderExtensions [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Commands.Flights.Submission.Rollout.UpdateCommand.Handler))] [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Commands.Flights.Submission.Rollout.HaltCommand.Handler))] [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Commands.Flights.Submission.Rollout.FinalizeCommand.Handler))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Commands.Reviews.ListCommand.Handler))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Commands.Reviews.GetCommand.Handler))] [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(PackageCommand.Handler))] [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(PublishCommand.Handler))] [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(MicrosoftStoreCLI.Handler))] @@ -59,6 +62,7 @@ public static IHostBuilder ConfigureStoreCLICommands(this IHostBuilder builder) .UseCommandHandler() .UseCommandHandler() .UseCommandHandler() + .UseCommandHandler() .UseCommandHandler() .UseCommandHandler() .ConfigureCommand() @@ -95,6 +99,9 @@ public static IHostBuilder ConfigureStoreCLICommands(this IHostBuilder builder) .UseCommandHandler() .UseCommandHandler() .UseCommandHandler() + .ConfigureCommand() + .UseCommandHandler() + .UseCommandHandler() .UseCommandHandler(); }); } diff --git a/README.md b/README.md index f01df15..4d53e6d 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,46 @@ The Microsoft Store Developer Command Line Interface is a cross-platform (Window ## Helpful links * [Documentation](https://aka.ms/msstoredevcli/docs) - Microsoft's official documentation on regards to available commands, installation steps, how to properly setup CI/CD environments, and general guidance. +## Reviews + +Read the Store reviews of a managed (MSIX) application: + +``` +msstore reviews list +msstore reviews get +``` + +`list` renders a table and supports `--startDate`, `--endDate`, `--top`, `--skip`, `--rating` and `--market`. With no date options every review is returned; pass `--startDate`/`--endDate` to narrow the range. The `Id` column is the value `reviews get` takes. + +> Responding to reviews is not supported. Microsoft documents its [Store reviews API](https://learn.microsoft.com/windows/uwp/monetize/submit-responses-to-app-reviews) as "currently not in a working state", and points to [Partner Center](https://learn.microsoft.com/windows/apps/publish/analyze-msi-exe/ratings-reviews-performance) instead. + +### Translating reviews + +The Microsoft Store returns no translated text and no language information for reviews, so `--translate` uses [Azure AI Translator](https://learn.microsoft.com/azure/ai-services/translator/) with a key you supply: + +``` +msstore reviews list --translate # translates into English +msstore reviews list --translate pt # translates into Portuguese +``` + +Provide the key through environment variables: + +| Variable | Required | Description | +| --- | --- | --- | +| `MSSTORE_TRANSLATOR_KEY` | Yes | The Azure AI Translator resource key. | +| `MSSTORE_TRANSLATOR_REGION` | Only for regional and multi-service resources | The resource region. Not needed for a global resource. | + +Or store them once, so they persist between runs: + +``` +msstore settings set-translator-key --region +msstore settings set-translator-key --clear +``` + +The key is held in the OS secure store; the region is not a secret and is saved in `settings.json`. + +Translation is billed per source character, per target language, against your own Azure subscription. + ## Contributing This project welcomes contributions and suggestions. Most contributions require you to agree to a