diff --git a/MSStore.API/Packaged/Models/PriceIds.cs b/MSStore.API/Packaged/Models/PriceIds.cs new file mode 100644 index 0000000..2187ce5 --- /dev/null +++ b/MSStore.API/Packaged/Models/PriceIds.cs @@ -0,0 +1,109 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Globalization; + +namespace MSStore.API.Packaged.Models +{ + /// + /// Well-known values for , and the rules for which + /// of them may be sent back to the Store submission API on an update. + /// + /// + /// These live outside on purpose. That type is serialized with + /// , so any member + /// added to it would show up in the request body. + /// + public static class PriceIds + { + /// + /// Sentinel meaning "the price tier is not set; use the base price for the app". + /// It is a legal value inside , but the + /// API also returns it as the base price of products managed by the newer + /// per-market pricing model - where it cannot be sent back. Updating a submission + /// with it fails with 'Base' is not a valid PriceId for base price. + /// + public const string Base = "Base"; + + /// The app is free. + public const string Free = "Free"; + + /// The app is not available in the given market. + public const string NotAvailable = "NotAvailable"; + + private const string TierPrefix = "Tier"; + + /// + /// Whether a price id read from a submission can be sent back unchanged on update. + /// + /// + /// Everything except (and a missing value) round-trips. + /// + /// An empty price id must never be sent. Update is a full replace with no patch + /// semantics, so anything the request does not state explicitly is reset to its default, + /// and the default is free. Verified against the API: a null price id, a pricing + /// object with the property removed, and an empty pricing object all answer + /// 200 OK and silently turn the product free. Omitting the property is therefore + /// not a way to leave the price untouched - there is no such way. + /// + /// + /// The price id to check. + /// true when is safe to send back. + public static bool IsRoundTrippable(string? priceId) => + !string.IsNullOrWhiteSpace(priceId) && + !string.Equals(priceId.Trim(), Base, StringComparison.OrdinalIgnoreCase); + + /// + /// Validates a user supplied price id and converts it to the casing the API expects. + /// + /// + /// Tier numbers are deliberately not range checked. The documented ranges + /// (Tier2-Tier96 and Tier1012-Tier1424) describe what a + /// dashboard offers, not what the API accepts, and isAdvancedPricingModel is + /// not a reliable way to tell the two apart - the API reports it inconsistently for + /// the same product. Let the service reject an out of range tier. + /// + /// The price id to normalize. + /// The normalized price id, when valid. + /// true when is a value the API accepts. + public static bool TryNormalize(string? priceId, out string? normalized) + { + normalized = null; + + if (string.IsNullOrWhiteSpace(priceId)) + { + return false; + } + + var trimmed = priceId.Trim(); + + if (string.Equals(trimmed, Free, StringComparison.OrdinalIgnoreCase)) + { + normalized = Free; + return true; + } + + if (string.Equals(trimmed, NotAvailable, StringComparison.OrdinalIgnoreCase)) + { + normalized = NotAvailable; + return true; + } + + if (!trimmed.StartsWith(TierPrefix, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var tier = trimmed[TierPrefix.Length..]; + + if (tier.Length == 0 || !int.TryParse(tier, NumberStyles.None, CultureInfo.InvariantCulture, out var tierNumber)) + { + return false; + } + + normalized = string.Concat(TierPrefix, tierNumber.ToString(CultureInfo.InvariantCulture)); + return true; + } + } +} diff --git a/MSStore.CLI.UnitTests/BaseCommandLineTest.cs b/MSStore.CLI.UnitTests/BaseCommandLineTest.cs index 8a13f5c..8049269 100644 --- a/MSStore.CLI.UnitTests/BaseCommandLineTest.cs +++ b/MSStore.CLI.UnitTests/BaseCommandLineTest.cs @@ -127,6 +127,21 @@ protected static void AssertBasedOnTestDataProjectSubPath(string[] testDataProje } } + /// + /// Reduces captured console output to plain text: strips the ANSI escape sequences + /// Spectre.Console emits for styling, and collapses the line breaks it inserts when + /// wrapping to the console width. Without this, an assertion on message text depends on + /// both the width and the colour support of whatever terminal the test ran under, which + /// differs between local runs and CI. + /// + /// The captured console output. + /// The text without styling, with every run of whitespace collapsed to one space. + protected static string PlainConsoleText(string text) + { + var withoutAnsi = System.Text.RegularExpressions.Regex.Replace(text, @"\x1B\[[0-9;]*[a-zA-Z]", string.Empty); + return System.Text.RegularExpressions.Regex.Replace(withoutAnsi, @"\s+", " "); + } + private readonly List _temporaryPayloadFiles = []; /// @@ -410,13 +425,17 @@ internal void AddFakeAccount(AccountEnrollment? accountEnrollment) }); } - protected void AddDefaultFakeSubmission(string listingDescription = "BaseListingDescription") + protected void AddDefaultFakeSubmission(string listingDescription = "BaseListingDescription", Pricing? pricing = null, bool withoutPricing = false) { var fakeSubmission = new DevCenterSubmission { Id = "123456789", ApplicationCategory = DevCenterApplicationCategory.NotSet, FileUploadUrl = "https://azureblob.com/fileupload", + + // Every real app submission comes back carrying a pricing object, so that is what + // the fixtures model. 'withoutPricing' exists only to cover the degenerate case. + Pricing = withoutPricing ? null : pricing ?? new Pricing { PriceId = PriceIds.Free }, ApplicationPackages = [ new ApplicationPackage @@ -573,9 +592,9 @@ internal void InitDefaultFlightSubmissionStatusResponseQueue() }); } - protected void AddDefaultFakeSuccessfulSubmission() + protected void AddDefaultFakeSuccessfulSubmission(Pricing? pricing = null, bool withoutPricing = false) { - AddDefaultFakeSubmission(); + AddDefaultFakeSubmission(pricing: pricing, withoutPricing: withoutPricing); InitDefaultSubmissionStatusResponseQueue(); FakeStorePackagedAPI diff --git a/MSStore.CLI.UnitTests/PublishCommandPricingUnitTests.cs b/MSStore.CLI.UnitTests/PublishCommandPricingUnitTests.cs new file mode 100644 index 0000000..1ecd3b8 --- /dev/null +++ b/MSStore.CLI.UnitTests/PublishCommandPricingUnitTests.cs @@ -0,0 +1,304 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.CommandLine; +using System.Text.Json; +using MSStore.API.Models; +using MSStore.API.Packaged.Models; +using MSStore.CLI.Commands; + +namespace MSStore.CLI.UnitTests +{ + /// + /// Guards the base price across a publish. + /// + /// + /// Verified against the live submission API. On update, the base price behaves like this: + /// + /// Free/Tier96/Tier1012/Tier1424 - 200 OK, value preserved. + /// Base - 400 'Base' is not a valid PriceId for base price. + /// empty - 200 OK, and the product silently becomes free. This is issue #112. + /// pricing omitted - 400 Pricing data was not provided in the request. + /// + /// + [TestClass] + public class PublishCommandPricingUnitTests : BaseCommandLineTest + { + public TestContext TestContext { get; set; } = null!; + + [TestInitialize] + public void Init() + { + FakeLogin(); + AddDefaultFakeAccount(); + AddFakeApps(); + } + + private async Task<((string Output, string Error) Result, DevCenterSubmission? Sent)> PublishMsixAsync( + Pricing? pricing, + int? expectedExitCode = 0, + bool withoutPricing = false, + params string[] extraArgs) + { + var path = CopyFilesRecursively("MSIXProject"); + var msixPath = Path.Combine(path, "test.msix"); + + AddDefaultFakeSuccessfulSubmission(pricing, withoutPricing); + + DevCenterSubmission? sent = null; + FakeStorePackagedAPI + .Setup(x => x.UpdateSubmissionAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback((_, _, s, _) => sent = s) + .ReturnsAsync((string _, string _, DevCenterSubmission s, CancellationToken _) => s); + + string[] args = ["publish", msixPath, "--appId", FakeApps[0].Id!, "--verbose", .. extraArgs]; + + var result = await ParseAndInvokeAsync(args, expectedExitCode); + + return (result, sent); + } + + [TestMethod] + [DataRow("Tier1012")] + [DataRow("Tier1424")] + [DataRow("Tier96")] + [DataRow("Free")] + public async Task PublishShouldSendThePriceBackUnchanged(string priceId) + { + // The core regression for issue #112: publishing must never alter the base price. + var (result, sent) = await PublishMsixAsync(new Pricing { PriceId = priceId }); + + PlainConsoleText(result.Error).Should().Contain("Submission commit success! Here is some data:"); + + sent.Should().NotBeNull(); + sent!.Pricing.Should().NotBeNull(); + sent.Pricing!.PriceId.Should().Be(priceId); + } + + [TestMethod] + public async Task PublishShouldNeverSendAnEmptyPrice() + { + // Commit 0b4b0bc set PriceId to null here. The API answers 200 OK and resets the + // product to free, so this can only be caught by inspecting the outgoing payload. + var (_, sent) = await PublishMsixAsync( + new Pricing { PriceId = "Tier1012", IsAdvancedPricingModel = true }); + + sent!.Pricing!.PriceId.Should().NotBeNullOrWhiteSpace(); + sent.Pricing.PriceId.Should().Be("Tier1012"); + } + + [TestMethod] + public async Task PublishShouldIgnoreIsAdvancedPricingModel() + { + // isAdvancedPricingModel only describes which tier range a dashboard offers, and the + // API reports it inconsistently for the same product. It must not drive any decision. + var (result, sent) = await PublishMsixAsync( + new Pricing { PriceId = "Tier1012", IsAdvancedPricingModel = true }); + + PlainConsoleText(result.Error).Should().Contain("Submission commit success! Here is some data:"); + sent!.Pricing!.PriceId.Should().Be("Tier1012"); + } + + [TestMethod] + public async Task PublishShouldStopWhenThePriceCannotBePreserved() + { + var (result, sent) = await PublishMsixAsync(new Pricing { PriceId = "Base" }, -1); + + PlainConsoleText(result.Error).Should().Contain("Could not preserve this product's price"); + PlainConsoleText(result.Error).Should().Contain("--priceId"); + + // Nothing may be sent, otherwise the product would be reset to free. + sent.Should().BeNull(); + + FakeStorePackagedAPI + .Verify( + x => x.DeleteSubmissionAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Once); + } + + [TestMethod] + public async Task PublishShouldNotClaimOnlyFreeProductsAreSupported() + { + // The old message said "App updates are supported only for Free products", which is + // wrong: a product with a real tier publishes fine, as the tests above show. + var (result, _) = await PublishMsixAsync(new Pricing { PriceId = "Base" }, -1); + + PlainConsoleText(result.Error).Should().NotContain("only for Free products"); + } + + [TestMethod] + public async Task PublishWithPriceIdShouldRecoverAProductWhoseBasePriceIsNotRoundTrippable() + { + var (result, sent) = await PublishMsixAsync( + new Pricing { PriceId = "Base" }, + 0, + false, + "--priceId", + "Tier1012"); + + PlainConsoleText(result.Error).Should().Contain("Submission commit success! Here is some data:"); + sent!.Pricing!.PriceId.Should().Be("Tier1012"); + } + + [TestMethod] + public async Task PublishWithPriceIdShouldOverrideAnExistingTier() + { + var (_, sent) = await PublishMsixAsync( + new Pricing { PriceId = "Tier1012" }, + 0, + false, + "--priceId", + "Tier1424"); + + sent!.Pricing!.PriceId.Should().Be("Tier1424"); + } + + [TestMethod] + public async Task PublishShouldStopWhenTheProductHasNoPricingAtAll() + { + // Missing pricing is just as unsendable as a bad price id: the API answers + // "Pricing data was not provided in the request.". Fail fast with guidance instead + // of letting UpdateSubmissionAsync surface a raw 400. + var (result, sent) = await PublishMsixAsync(null, -1, withoutPricing: true); + + PlainConsoleText(result.Error).Should().Contain("Could not preserve this product's price"); + PlainConsoleText(result.Error).Should().Contain("returned no pricing for this product"); + PlainConsoleText(result.Error).Should().Contain("--priceId"); + + sent.Should().BeNull(); + } + + [TestMethod] + public async Task PublishWithPriceIdShouldApplyEvenWhenTheProductHasNoPricingAtAll() + { + // The API rejects an update that omits pricing, so an explicit price has to be + // materialized rather than silently dropped. + var (result, sent) = await PublishMsixAsync(null, 0, true, "--priceId", "Tier1012"); + + PlainConsoleText(result.Error).Should().Contain("Submission commit success! Here is some data:"); + sent!.Pricing.Should().NotBeNull(); + sent.Pricing!.PriceId.Should().Be("Tier1012"); + } + + [TestMethod] + public void PricingMustAlwaysSerializeThePriceIdProperty() + { + // Guards against "tidying" the payload by omitting a null PriceId. Update has no + // patch semantics: a pricing object with the property removed is accepted with + // 200 OK and turns the product free, exactly like sending it as null. Adding + // JsonIgnore(WhenWritingNull) to Pricing.PriceId would silently reintroduce #112. + var json = JsonSerializer.Serialize( + new DevCenterSubmission { Pricing = new Pricing { PriceId = null } }, + SourceGenerationContext.GetCustom().DevCenterSubmission); + + json.Should().Contain("\"PriceId\""); + } + + [TestMethod] + public void PricingShouldSerializeARealTierVerbatim() + { + var json = JsonSerializer.Serialize( + new DevCenterSubmission { Pricing = new Pricing { PriceId = "Tier1012" } }, + SourceGenerationContext.GetCustom().DevCenterSubmission); + + json.Should().Contain("\"PriceId\":\"Tier1012\""); + } + + [TestMethod] + [DataRow("Tier1012")] + [DataRow("Free")] + [DataRow("NotAvailable")] + [DataRow("Tier2")] + public void RoundTrippablePriceIdsAreSentBackUnchanged(string priceId) + { + PriceIds.IsRoundTrippable(priceId).Should().BeTrue(); + } + + [TestMethod] + [DataRow(null)] + [DataRow("")] + [DataRow(" ")] + [DataRow("Base")] + [DataRow("base")] + [DataRow("BASE")] + [DataRow("Base ")] + [DataRow(" Base")] + [DataRow(" base ")] + public void UnsendablePriceIdsAreNotRoundTrippable(string? priceId) + { + // Whitespace must not smuggle the Base sentinel past the guard: TryNormalize already + // treats surrounding whitespace as insignificant, and sending "Base " is still a 400. + PriceIds.IsRoundTrippable(priceId).Should().BeFalse(); + } + + [TestMethod] + public void PlainConsoleTextShouldStripStylingAndWrapping() + { + // Reproduces what CI actually captured. Spectre emitted bold escapes around "Free" + // and wrapped the sentence, so a verbatim assertion passed locally (no colour) and + // failed on every CI runner. Asserting through PlainConsoleText removes both. + var captured = + "The submission API would accept that and silently reset the product to \u001b[1mFree\u001b[0m,\n" + + "so the update has been\nstopped instead."; + + PlainConsoleText(captured) + .Should() + .Contain("silently reset the product to Free") + .And + .Contain("so the update has been stopped instead."); + } + + private static ParseResult ParsePublish(params string[] args) => + new PublishCommand().Parse(args); + + [TestMethod] + public void PublishCommandPriceIdShouldDefaultToNullWhenOmitted() + { + ParsePublish("publish", ".") + .GetValue(PublishCommand.PriceIdOption) + .Should() + .BeNull(); + } + + [TestMethod] + [DataRow("Tier1012", "Tier1012")] + [DataRow("tier1012", "Tier1012")] + [DataRow("TIER1012", "Tier1012")] + [DataRow(" Tier1012 ", "Tier1012")] + [DataRow("Free", "Free")] + [DataRow("free", "Free")] + [DataRow("NotAvailable", "NotAvailable")] + [DataRow("Tier2", "Tier2")] + public void PublishCommandPriceIdShouldNormalizeAcceptedValues(string input, string expected) + { + var parseResult = ParsePublish("publish", ".", "--priceId", input); + + parseResult.Errors.Should().BeEmpty(); + parseResult.GetValue(PublishCommand.PriceIdOption).Should().Be(expected); + } + + [TestMethod] + [DataRow("Base")] + [DataRow("Tier")] + [DataRow("1012")] + [DataRow("TierAbc")] + [DataRow("Tier-1")] + [DataRow("not-a-tier")] + public void PublishCommandPriceIdShouldRejectValuesTheApiWouldNotAccept(string input) + { + // "Base" is rejected on purpose: it is exactly the value that cannot be sent back. + ParsePublish("publish", ".", "--priceId", input) + .Errors + .Should() + .ContainSingle() + .Which + .Message + .Should() + .Be("Invalid price id. The value must be 'Free', 'NotAvailable', or a tier such as 'Tier1012'."); + } + } +} diff --git a/MSStore.CLI.UnitTests/SubmissionCommandPackagedUnitTests.cs b/MSStore.CLI.UnitTests/SubmissionCommandPackagedUnitTests.cs index 7456a63..09bee59 100644 --- a/MSStore.CLI.UnitTests/SubmissionCommandPackagedUnitTests.cs +++ b/MSStore.CLI.UnitTests/SubmissionCommandPackagedUnitTests.cs @@ -46,8 +46,8 @@ public async Task PackagedSubmissionStatusCommand() FakeApps[0].Id! ]); - result.Error.Should().Contain("Code1"); - result.Error.Should().Contain("Detail1"); + PlainConsoleText(result.Error).Should().Contain("Code1"); + PlainConsoleText(result.Error).Should().Contain("Detail1"); } [TestMethod] @@ -131,6 +131,7 @@ public async Task PackagedSubmissionUpdateCommand() FakeApps[0].Id!, @" { +""Pricing"": { ""PriceId"": ""Free"" }, ""ApplicationPackages"": [ { @@ -140,16 +141,169 @@ public async Task PackagedSubmissionUpdateCommand() }" ]); - result.Error.Should().Contain("Updating submission product"); + PlainConsoleText(result.Error).Should().Contain("Updating submission product"); result.Output.Should().Contain("\"FileUploadUrl\": \"https://azureblob.com/fileupload\""); } + [TestMethod] + public async Task PackagedSubmissionUpdateCommandShouldAcceptAPayloadCarryingARealTier() + { + // A per-market priced product can only be updated by naming a real tier in the + // payload. Blocking on the *current* submission's price would reject this. + FakeApps[0].PendingApplicationSubmission = new ApplicationSubmissionInfo + { + Id = "123456789" + }; + + AddDefaultFakeSubmission(pricing: new Pricing { PriceId = "Base" }); + + DevCenterSubmission? sent = null; + FakeStorePackagedAPI + .Setup(x => x.UpdateSubmissionAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback((_, _, s, _) => sent = s) + .ReturnsAsync((string _, string _, DevCenterSubmission s, CancellationToken _) => s); + + var result = await ParseAndInvokeAsync( + [ + "submission", + "update", + FakeApps[0].Id!, + @" +{ +""Pricing"": { ""PriceId"": ""Tier1012"" }, +""ApplicationPackages"": + [ + { + ""FileName"":""C:\\temp\\installer.msix"" + } + ] +}" + ]); + + PlainConsoleText(result.Error).Should().Contain("Updating submission product"); + sent!.Pricing!.PriceId.Should().Be("Tier1012"); + } + + [TestMethod] + public async Task PackagedSubmissionUpdateCommandShouldRejectAPayloadThatWouldResetThePrice() + { + // "Base" is what the API hands back for a per-market priced product, and sending it + // straight back is rejected with "'Base' is not a valid PriceId for base price." + var result = await ParseAndInvokeAsync( + [ + "submission", + "update", + FakeApps[0].Id!, + @"{ ""Pricing"": { ""PriceId"": ""Base"" } }" + ], -1); + + PlainConsoleText(result.Error).Should().Contain("sets 'Pricing.PriceId' to 'Base'"); + PlainConsoleText(result.Error).Should().Contain("which the submission API rejects"); + PlainConsoleText(result.Error).Should().NotContain("only for Free products"); + + FakeStorePackagedAPI + .Verify( + x => x.UpdateSubmissionAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task PackagedSubmissionUpdateCommandShouldRejectAPayloadWithAnEmptyPrice() + { + // An empty price is the dangerous one: the API answers 200 OK and silently resets + // the product to free. + var result = await ParseAndInvokeAsync( + [ + "submission", + "update", + FakeApps[0].Id!, + @"{ ""Pricing"": { ""TrialPeriod"": ""NoFreeTrial"" } }" + ], -1); + + PlainConsoleText(result.Error).Should().Contain("does not set 'Pricing.PriceId'"); + PlainConsoleText(result.Error).Should().Contain("silently reset the product to Free"); + + FakeStorePackagedAPI + .Verify( + x => x.UpdateSubmissionAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task PackagedSubmissionUpdateCommandShouldRejectAPayloadWithNoPricingObject() + { + // An update replaces the whole submission, so a payload without pricing is rejected + // outright ("Pricing data was not provided in the request."). Stopping here also + // avoids stranding the draft this command just created. + var result = await ParseAndInvokeAsync( + [ + "submission", + "update", + FakeApps[0].Id!, + @"{ ""ApplicationPackages"": [ { ""FileName"": ""test.msix"" } ] }" + ], -1); + + PlainConsoleText(result.Error).Should().Contain("has no 'Pricing' object"); + + FakeStorePackagedAPI + .Verify( + x => x.UpdateSubmissionAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + + // The draft was created by this command, so it must not be left behind. + FakeStorePackagedAPI + .Verify( + x => x.DeleteSubmissionAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Once); + } + + [TestMethod] + public async Task PackagedSubmissionUpdateCommandShouldNotDeleteADraftItDidNotCreate() + { + FakeApps[0].PendingApplicationSubmission = new ApplicationSubmissionInfo + { + Id = "123456789" + }; + + var result = await ParseAndInvokeAsync( + [ + "submission", + "update", + FakeApps[0].Id!, + @"{ ""Pricing"": { ""PriceId"": ""Base"" } }" + ], -1); + + PlainConsoleText(result.Error).Should().Contain("which the submission API rejects"); + + FakeStorePackagedAPI + .Verify( + x => x.DeleteSubmissionAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + [TestMethod] public async Task PackagedSubmissionUpdateCommandWithPayloadOption() { var payloadFilePath = CreateTemporaryPayloadFile( @" { +""Pricing"": { ""PriceId"": ""Free"" }, ""ApplicationPackages"": [ { @@ -167,7 +321,7 @@ public async Task PackagedSubmissionUpdateCommandWithPayloadOption() payloadFilePath ]); - result.Error.Should().Contain("Updating submission product"); + PlainConsoleText(result.Error).Should().Contain("Updating submission product"); result.Output.Should().Contain("\"FileUploadUrl\": \"https://azureblob.com/fileupload\""); } @@ -177,6 +331,7 @@ public async Task PackagedSubmissionUpdateCommandWithFilePathArgument() var payloadFilePath = CreateTemporaryPayloadFile( @" { +""Pricing"": { ""PriceId"": ""Free"" }, ""ApplicationPackages"": [ { @@ -193,7 +348,7 @@ public async Task PackagedSubmissionUpdateCommandWithFilePathArgument() payloadFilePath ]); - result.Error.Should().Contain("Updating submission product"); + PlainConsoleText(result.Error).Should().Contain("Updating submission product"); result.Output.Should().Contain("\"FileUploadUrl\": \"https://azureblob.com/fileupload\""); } @@ -205,6 +360,7 @@ public async Task PackagedSubmissionUpdateCommandWithStandardInputArgument() .ReturnsAsync( @" { +""Pricing"": { ""PriceId"": ""Free"" }, ""ApplicationPackages"": [ { @@ -221,7 +377,7 @@ public async Task PackagedSubmissionUpdateCommandWithStandardInputArgument() "-" ]); - result.Error.Should().Contain("Updating submission product"); + PlainConsoleText(result.Error).Should().Contain("Updating submission product"); result.Output.Should().Contain("\"FileUploadUrl\": \"https://azureblob.com/fileupload\""); } @@ -236,6 +392,7 @@ public async Task PackagedSubmissionUpdateCommandWithRedirectedStandardInput() .ReturnsAsync( @" { +""Pricing"": { ""PriceId"": ""Free"" }, ""ApplicationPackages"": [ { @@ -251,7 +408,7 @@ public async Task PackagedSubmissionUpdateCommandWithRedirectedStandardInput() FakeApps[0].Id! ]); - result.Error.Should().Contain("Updating submission product"); + PlainConsoleText(result.Error).Should().Contain("Updating submission product"); result.Output.Should().Contain("\"FileUploadUrl\": \"https://azureblob.com/fileupload\""); } @@ -265,7 +422,7 @@ public async Task PackagedSubmissionUpdateCommandWithNoPayload() FakeApps[0].Id! ], 1); - result.Error.Should().Contain("No 'product' was provided."); + PlainConsoleText(result.Error).Should().Contain("No 'product' was provided."); } [TestMethod] @@ -279,7 +436,7 @@ public async Task PackagedSubmissionUpdateCommandWithUnknownFilePath() "this-file-does-not-exist.json" ], 1); - result.Error.Should().Contain("is neither a JSON payload nor a path to an existing file"); + PlainConsoleText(result.Error).Should().Contain("is neither a JSON payload nor a path to an existing file"); } [TestMethod] @@ -288,6 +445,7 @@ public async Task PackagedSubmissionUpdateCommandWithBothInlineJsonAndPayloadOpt var payloadFilePath = CreateTemporaryPayloadFile( @" { +""Pricing"": { ""PriceId"": ""Free"" }, ""ApplicationPackages"": [ { @@ -306,7 +464,7 @@ public async Task PackagedSubmissionUpdateCommandWithBothInlineJsonAndPayloadOpt payloadFilePath ], 1); - result.Error.Should().Contain("Use only one of them."); + PlainConsoleText(result.Error).Should().Contain("Use only one of them."); } [TestMethod] @@ -319,6 +477,7 @@ public async Task PackagedSubmissionUpdateCommandWithPayloadBiggerThanTheCommand var payloadFilePath = CreateTemporaryPayloadFile( @" { +""Pricing"": { ""PriceId"": ""Free"" }, ""Listings"": { ""en-us"": @@ -348,7 +507,7 @@ public async Task PackagedSubmissionUpdateCommandWithPayloadBiggerThanTheCommand It.IsAny()), Times.Once); - result.Error.Should().Contain("Updating submission product"); + PlainConsoleText(result.Error).Should().Contain("Updating submission product"); result.Output.Should().Contain("\"FileUploadUrl\": \"https://azureblob.com/fileupload\""); } @@ -362,6 +521,7 @@ public async Task PackagedSubmissionUpdateMetadataCommand() FakeApps[0].Id!, @" { +""Pricing"": { ""PriceId"": ""Free"" }, ""Listings"": { ""en-us"": @@ -375,7 +535,7 @@ public async Task PackagedSubmissionUpdateMetadataCommand() }" ]); - result.Error.Should().Contain("Updating submission product"); + PlainConsoleText(result.Error).Should().Contain("Updating submission product"); result.Output.Should().Contain("\"FileUploadUrl\": \"https://azureblob.com/fileupload\""); } @@ -385,6 +545,7 @@ public async Task PackagedSubmissionUpdateMetadataCommandWithPayloadOption() var payloadFilePath = CreateTemporaryPayloadFile( @" { +""Pricing"": { ""PriceId"": ""Free"" }, ""Listings"": { ""en-us"": @@ -406,7 +567,7 @@ public async Task PackagedSubmissionUpdateMetadataCommandWithPayloadOption() payloadFilePath ]); - result.Error.Should().Contain("Updating submission product"); + PlainConsoleText(result.Error).Should().Contain("Updating submission product"); result.Output.Should().Contain("\"FileUploadUrl\": \"https://azureblob.com/fileupload\""); } @@ -432,7 +593,7 @@ public async Task PackagedSubmissionPublishCommand() FakeApps[0].Id! ]); - result.Error.Should().Contain("Submission Committed with status"); + PlainConsoleText(result.Error).Should().Contain("Submission Committed with status"); } [TestMethod] @@ -452,7 +613,7 @@ public async Task PackagedSubmissionPollCommand() FakeApps[0].Id! ]); - result.Error.Should().Contain("Submission commit success!"); + PlainConsoleText(result.Error).Should().Contain("Submission commit success!"); } [TestMethod] @@ -476,8 +637,8 @@ public async Task PackagedSubmissionDeleteCommand() FakeConsole.Verify(x => x.YesNoConfirmationAsync(It.IsAny(), It.IsAny()), Times.Once); - result.Error.Should().Contain($"Found Pending Submission with Id '{FakeApps[0].PendingApplicationSubmission!.Id}'"); - result.Error.Should().Contain("Existing submission deleted!"); + PlainConsoleText(result.Error).Should().Contain($"Found Pending Submission with Id '{FakeApps[0].PendingApplicationSubmission!.Id}'"); + PlainConsoleText(result.Error).Should().Contain("Existing submission deleted!"); } } } \ No newline at end of file diff --git a/MSStore.CLI/Commands/InitCommand.cs b/MSStore.CLI/Commands/InitCommand.cs index 9801f6e..c8090f3 100644 --- a/MSStore.CLI/Commands/InitCommand.cs +++ b/MSStore.CLI/Commands/InitCommand.cs @@ -135,6 +135,7 @@ public InitCommand() Options.Add(ArchOption); Options.Add(VersionOption); Options.Add(PublishCommand.PackageRolloutPercentageOption); + Options.Add(PublishCommand.PriceIdOption); Options.Add(PublishCommand.UploadTimeoutOption); } @@ -175,6 +176,7 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio var flightId = parseResult.GetValue(PublishCommand.FlightIdOption); var version = parseResult.GetValue(VersionOption); var packageRolloutPercentage = parseResult.GetValue(PublishCommand.PackageRolloutPercentageOption); + var priceId = parseResult.GetValue(PublishCommand.PriceIdOption); var uploadTimeout = parseResult.GetValue(PublishCommand.UploadTimeoutOption); var output = parseResult.GetValue(OutputOption); var arch = parseResult.GetValue(ArchOption); @@ -363,7 +365,7 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio return await _telemetryClient.TrackCommandEventAsync(-5, props, ct); } - result = await projectPublisher.PublishAsync(pathOrUrl, app, flightId, outputDirectory, false, packageRolloutPercentage, uploadTimeout, storePackagedAPI, ct); + result = await projectPublisher.PublishAsync(pathOrUrl, app, flightId, outputDirectory, false, packageRolloutPercentage, priceId, uploadTimeout, storePackagedAPI, ct); } return await _telemetryClient.TrackCommandEventAsync(result, props, ct); diff --git a/MSStore.CLI/Commands/PublishCommand.cs b/MSStore.CLI/Commands/PublishCommand.cs index 00e26c7..cec9f7c 100644 --- a/MSStore.CLI/Commands/PublishCommand.cs +++ b/MSStore.CLI/Commands/PublishCommand.cs @@ -12,6 +12,7 @@ using System.Threading.Tasks; using Microsoft.ApplicationInsights; using Microsoft.Extensions.Logging; +using MSStore.API.Packaged.Models; using MSStore.CLI.Helpers; using MSStore.CLI.ProjectConfigurators; using MSStore.CLI.Services; @@ -28,6 +29,7 @@ internal class PublishCommand : Command internal static readonly Option FlightIdOption; internal static readonly Option PackageRolloutPercentageOption; + internal static readonly Option PriceIdOption; internal static readonly Option UploadTimeoutOption; private static readonly Option InputDirectoryOption; private static readonly Option AppIdOption; @@ -67,6 +69,26 @@ static PublishCommand() } }; + PriceIdOption = new Option("--priceId", "-pid") + { + Description = "Specifies the base price tier to set on the submission, for example 'Tier1012', 'Free' or 'NotAvailable'. Only needed when the Store reports a base price the submission API will not accept back, which happens when the price is managed per market from Partner Center. This sets a single base price for the product rather than preserving per-market prices, so consider '--noCommit' to review the submission before it goes live.", + CustomParser = result => + { + if (result.Tokens.Count == 0) + { + return null; + } + + if (!PriceIds.TryNormalize(result.Tokens.Single().Value, out var normalized)) + { + result.AddError("Invalid price id. The value must be 'Free', 'NotAvailable', or a tier such as 'Tier1012'."); + return null; + } + + return normalized; + } + }; + InputDirectoryOption = new Option("--inputDirectory", "-i") { Description = "The directory where the '.msix' or '.msixupload' file to be used for the publishing command. If not provided, the cli will try to find the best candidate based on the 'pathOrUrl' argument.", @@ -143,6 +165,7 @@ public PublishCommand() Options.Add(NoCommitOption); Options.Add(FlightIdOption); Options.Add(PackageRolloutPercentageOption); + Options.Add(PriceIdOption); Options.Add(UploadTimeoutOption); } @@ -165,6 +188,7 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio var appId = parseResult.GetValue(AppIdOption); var flightId = parseResult.GetValue(FlightIdOption); var packageRolloutPercentage = parseResult.GetValue(PackageRolloutPercentageOption); + var priceId = parseResult.GetValue(PriceIdOption); var inputDirectory = parseResult.GetValue(InputDirectoryOption); var noCommit = parseResult.GetRequiredValue(NoCommitOption); var uploadTimeout = parseResult.GetValue(UploadTimeoutOption); @@ -214,7 +238,7 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio } return await _telemetryClient.TrackCommandEventAsync( - await projectPublisher.PublishAsync(pathOrUrl, app, flightId, inputDirectory, noCommit, packageRolloutPercentage, uploadTimeout, storePackagedAPI, ct), props, ct); + await projectPublisher.PublishAsync(pathOrUrl, app, flightId, inputDirectory, noCommit, packageRolloutPercentage, priceId, uploadTimeout, storePackagedAPI, ct), props, ct); } } } diff --git a/MSStore.CLI/Commands/Submission/UpdateCommand.cs b/MSStore.CLI/Commands/Submission/UpdateCommand.cs index 7d3792d..f0e0de7 100644 --- a/MSStore.CLI/Commands/Submission/UpdateCommand.cs +++ b/MSStore.CLI/Commands/Submission/UpdateCommand.cs @@ -12,6 +12,7 @@ using MSStore.API; using MSStore.API.Models; using MSStore.API.Packaged; +using MSStore.API.Packaged.Models; using MSStore.CLI.Helpers; using MSStore.CLI.Services; using Spectre.Console; @@ -48,6 +49,14 @@ public class Handler(ILogger logger, IStoreAPIFactory sto private readonly IAnsiConsole _ansiConsole = ansiConsole ?? throw new ArgumentNullException(nameof(ansiConsole)); private readonly TelemetryClient _telemetryClient = telemetryClient ?? throw new ArgumentNullException(nameof(telemetryClient)); + /// + /// Updates a packaged submission. + /// + /// + /// The updated submission, or null when the update did not happen. A failure + /// must never be signalled with an int: the caller only checks for null, + /// so a boxed code would be reported as success and printed as the command's output. + /// public static async Task PackagedUpdateCommandAsync(IAnsiConsole ansiConsole, IStoreAPIFactory storeAPIFactory, string product, string productId, ILogger logger, CancellationToken ct) { var updateSubmission = JsonSerializer.Deserialize(product, SourceGenerationContext.GetCustom().DevCenterSubmission); @@ -84,10 +93,11 @@ public class Handler(ILogger logger, IStoreAPIFactory sto if (storePackagedAPI == null || application == null || application?.Id == null) { - return 1; + return null; } string? submissionId = application.PendingApplicationSubmission?.Id; + var draftWasCreatedHere = submissionId == null; if (submissionId == null) { @@ -102,12 +112,35 @@ public class Handler(ILogger logger, IStoreAPIFactory sto } } - var currentSubmission = await storePackagedAPI.GetSubmissionAsync(application.Id, submissionId, ct); - if (currentSubmission?.Pricing != null && currentSubmission?.Pricing?.PriceId == "Base") + // Only the payload actually being sent matters here. Blocking on the *current* + // submission would reject a perfectly good update whose JSON already carries a + // valid tier, which is the one way a per-market priced product can be updated. + var updatedPriceId = updateSubmission.Pricing?.PriceId; + if (updateSubmission.Pricing == null || !PriceIds.IsRoundTrippable(updatedPriceId)) { - await storePackagedAPI.DeleteSubmissionAsync(application.Id, submissionId: currentSubmission.Id!, ct); - ansiConsole.MarkupLine("[red bold]App updates are supported only for Free products.[/]"); - return -1; + // Clean up after ourselves, but never delete a draft the caller already had. + if (draftWasCreatedHere) + { + await storePackagedAPI.DeleteSubmissionAsync(application.Id, submissionId, ct); + } + + if (updateSubmission.Pricing == null) + { + ansiConsole.MarkupLine("[red bold]The JSON you provided has no 'Pricing' object.[/]"); + ansiConsole.MarkupLine("An update replaces the whole submission, and the submission API rejects one that does not carry pricing."); + } + else if (string.IsNullOrWhiteSpace(updatedPriceId)) + { + ansiConsole.MarkupLine("[red bold]The JSON you provided does not set 'Pricing.PriceId'.[/]"); + ansiConsole.MarkupLine("The submission API would accept that and silently reset the product to [bold]Free[/], so the update has been stopped instead."); + } + else + { + ansiConsole.MarkupLine($"[red bold]The JSON you provided sets 'Pricing.PriceId' to '{updatedPriceId.EscapeMarkup()}', which the submission API rejects.[/]"); + } + + ansiConsole.MarkupLine("Set 'Pricing.PriceId' to a real tier (for example 'Tier1012'), 'Free', or 'NotAvailable' and try again."); + return null; } return await ansiConsole.Status().StartAsync("Updating submission product", async ctx => diff --git a/MSStore.CLI/Helpers/IStorePackagedAPIExtensions.cs b/MSStore.CLI/Helpers/IStorePackagedAPIExtensions.cs index b2b41ad..b3b8c31 100644 --- a/MSStore.CLI/Helpers/IStorePackagedAPIExtensions.cs +++ b/MSStore.CLI/Helpers/IStorePackagedAPIExtensions.cs @@ -352,6 +352,7 @@ public static async Task PublishAsync( IEnumerable input, bool noCommit, float? packageRolloutPercentage, + string? priceId, long uploadTimeout, IBrowserLauncher browserLauncher, IConsoleReader consoleReader, @@ -493,10 +494,10 @@ public static async Task PublishAsync( DevCenterSubmission? devCenterSubmission = submission as DevCenterSubmission; DevCenterFlightSubmission? devCenterFlightSubmission = submission as DevCenterFlightSubmission; - if (devCenterSubmission?.Pricing != null && devCenterSubmission?.Pricing?.PriceId == "Base") + if (devCenterSubmission != null + && !TryPreservePricing(ansiConsole, devCenterSubmission, priceId, logger)) { await storePackagedAPI.DeleteSubmissionAsync(app.Id, submission.Id, ct); - ansiConsole.MarkupLine("[red bold]App updates are supported only for Free products.[/]"); return -1; } @@ -785,6 +786,79 @@ private static async Task CreateImageAsync(string listingKey, Image image, return false; } + /// + /// Makes sure publishing never changes the product's price. + /// + /// + /// The submission API replaces the whole submission on update - there are no patch + /// semantics, so anything the request does not state explicitly is reset to its default. + /// A complete and valid pricing object therefore has to be sent back every time, + /// even when publishing has no interest in the price. For a product on the newer + /// per-market pricing model the API returns , and none of the + /// ways of expressing "leave the price alone" work: + /// + /// - rejected with 'Base' is not a valid PriceId for base price. + /// an empty value, the property removed, or an empty pricing object - all accepted with 200 OK, and the product silently becomes free. + /// a null or omitted pricing - rejected with Pricing data was not provided in the request. + /// + /// Nor can the real price be looked up: it is absent from the application resource, the + /// newer submission API does not know packaged products, and no API exposes the price + /// tier table. So for those products the price can only come from the caller. + /// + /// Any other price id (Free, NotAvailable, Tier1012, ...) round-trips + /// unchanged, so paid products publish fine as long as we leave the value alone. + /// + /// + /// false when the price cannot be preserved and publishing must stop. + internal static bool TryPreservePricing(IAnsiConsole ansiConsole, DevCenterSubmission submission, string? priceIdOverride, ILogger logger) + { + if (priceIdOverride != null) + { + logger.LogInformation("Overriding PriceId '{OriginalPriceId}' with '{PriceIdOverride}'.", submission.Pricing?.PriceId, priceIdOverride); + ansiConsole.MarkupLine($"Setting the base price to [green]{priceIdOverride.EscapeMarkup()}[/]."); + + // A product whose price is managed per market can come back without any pricing + // at all, and the API rejects an update that omits it. + submission.Pricing ??= new Pricing(); + submission.Pricing.PriceId = priceIdOverride; + return true; + } + + if (submission.Pricing != null && PriceIds.IsRoundTrippable(submission.Pricing.PriceId)) + { + return true; + } + + // Neither a missing pricing object nor an unusable price id can be sent, but they fail + // differently: the first two are rejected outright, an empty price id is accepted and + // silently turns the product free. Stop for all three, and say which one it is. + var priceId = submission.Pricing?.PriceId; + + ansiConsole.MarkupLine("[red bold]Could not preserve this product's price.[/]"); + + if (submission.Pricing == null) + { + logger.LogError("Cannot preserve the product's price: the submission carries no pricing, which the API rejects on update."); + ansiConsole.MarkupLine("The Store returned no pricing for this product, and the submission API rejects an update that does not carry one."); + } + else if (string.IsNullOrWhiteSpace(priceId)) + { + logger.LogError("Cannot preserve the product's price: the submission has no PriceId, and sending that resets the product to free."); + ansiConsole.MarkupLine("The Store returned no base price for this product. The submission API would accept that and silently reset the product to [bold]Free[/]."); + } + else + { + logger.LogError("Cannot preserve the product's price: the submission has PriceId '{PriceId}', which the API rejects on update.", priceId); + ansiConsole.MarkupLine($"The Store returned a base price of [yellow]'{priceId.EscapeMarkup()}'[/], which the submission API rejects on update. This happens when the price is managed per market from Partner Center."); + } + + ansiConsole.MarkupLine("Publishing has been stopped so the price is left as it is."); + ansiConsole.MarkupLine("Re-run with [green]--priceId[/] to state the base price explicitly (for example [green]--priceId Tier1012[/]), or publish this submission from Partner Center."); + ansiConsole.MarkupLine("[yellow]--priceId[/] sets a single base price for the product rather than preserving per-market prices, so publishing from Partner Center is the safer option when this product is priced per market."); + + return false; + } + private static async Task FulfillApplicationAsync(IAnsiConsole ansiConsole, DevCenterApplication app, DevCenterSubmission submission, FirstSubmissionDataCallback firstSubmissionDataCallback, AllowTargetFutureDeviceFamily[] allowTargetFutureDeviceFamilies, IConsoleReader consoleReader, IEnvironmentInformationService environmentInformationService, ILogger logger, CancellationToken ct) { if (submission.ApplicationCategory == DevCenterApplicationCategory.NotSet) diff --git a/MSStore.CLI/ProjectConfigurators/ElectronProjectConfigurator.cs b/MSStore.CLI/ProjectConfigurators/ElectronProjectConfigurator.cs index 9ee24f1..1f2d5a3 100644 --- a/MSStore.CLI/ProjectConfigurators/ElectronProjectConfigurator.cs +++ b/MSStore.CLI/ProjectConfigurators/ElectronProjectConfigurator.cs @@ -406,7 +406,7 @@ private async Task EnsureElectronManifestAsync(FileInfo? fileInfo, CancellationT _electronManifest ??= await _electronManifestManager.LoadAsync(fileInfo, ct); } - public override async Task PublishAsync(string pathOrUrl, DevCenterApplication? app, string? flightId, DirectoryInfo? inputDirectory, bool noCommit, float? packageRolloutPercentage, long uploadTimeout, IStorePackagedAPI storePackagedAPI, CancellationToken ct) + public override async Task PublishAsync(string pathOrUrl, DevCenterApplication? app, string? flightId, DirectoryInfo? inputDirectory, bool noCommit, float? packageRolloutPercentage, string? priceId, long uploadTimeout, IStorePackagedAPI storePackagedAPI, CancellationToken ct) { if (_electronManifest == null) { @@ -414,7 +414,7 @@ public override async Task PublishAsync(string pathOrUrl, DevCenterApplicat await EnsureElectronManifestAsync(manifestFile, ct); } - return await base.PublishAsync(pathOrUrl, app, flightId, inputDirectory, noCommit, packageRolloutPercentage, uploadTimeout, storePackagedAPI, ct); + return await base.PublishAsync(pathOrUrl, app, flightId, inputDirectory, noCommit, packageRolloutPercentage, priceId, uploadTimeout, storePackagedAPI, ct); } } } \ No newline at end of file diff --git a/MSStore.CLI/ProjectConfigurators/FileProjectConfigurator.cs b/MSStore.CLI/ProjectConfigurators/FileProjectConfigurator.cs index d4b755a..eb1515e 100644 --- a/MSStore.CLI/ProjectConfigurators/FileProjectConfigurator.cs +++ b/MSStore.CLI/ProjectConfigurators/FileProjectConfigurator.cs @@ -138,7 +138,7 @@ protected static FileInfo FindFile(DirectoryInfo projectRootPath, string searchP return Task.FromResult<(string, List)>((description, images)); } - public virtual async Task PublishAsync(string pathOrUrl, DevCenterApplication? app, string? flightId, DirectoryInfo? inputDirectory, bool noCommit, float? packageRolloutPercentage, long uploadTimeout, IStorePackagedAPI storePackagedAPI, CancellationToken ct) + public virtual async Task PublishAsync(string pathOrUrl, DevCenterApplication? app, string? flightId, DirectoryInfo? inputDirectory, bool noCommit, float? packageRolloutPercentage, string? priceId, long uploadTimeout, IStorePackagedAPI storePackagedAPI, CancellationToken ct) { var (projectRootPath, projectFile) = GetInfo(pathOrUrl); @@ -181,7 +181,7 @@ public virtual async Task PublishAsync(string pathOrUrl, DevCenterApplicati Logger.LogInformation("Trying to publish these {FileCount} files: {FileNames}", packageFiles.Count(), string.Join(", ", packageFiles.Select(f => $"'{f.FullName}'"))); - return await storePackagedAPI.PublishAsync(ErrorAnsiConsole, app, flightId, GetFirstSubmissionDataAsync, AllowTargetFutureDeviceFamilies, output, packageFiles, noCommit, packageRolloutPercentage, uploadTimeout, _browserLauncher, _consoleReader, _zipFileManager, _fileDownloader, _azureBlobManager, _environmentInformationService, _logger, ct); + return await storePackagedAPI.PublishAsync(ErrorAnsiConsole, app, flightId, GetFirstSubmissionDataAsync, AllowTargetFutureDeviceFamilies, output, packageFiles, noCommit, packageRolloutPercentage, priceId, uploadTimeout, _browserLauncher, _consoleReader, _zipFileManager, _fileDownloader, _azureBlobManager, _environmentInformationService, _logger, ct); } protected virtual DirectoryInfo GetInputDirectory(DirectoryInfo projectRootPath) diff --git a/MSStore.CLI/ProjectConfigurators/IProjectPublisher.cs b/MSStore.CLI/ProjectConfigurators/IProjectPublisher.cs index af06f30..6bb88d5 100644 --- a/MSStore.CLI/ProjectConfigurators/IProjectPublisher.cs +++ b/MSStore.CLI/ProjectConfigurators/IProjectPublisher.cs @@ -17,6 +17,6 @@ internal interface IProjectPublisher SearchOption PackageFilesSearchOption { get; } AllowTargetFutureDeviceFamily[] AllowTargetFutureDeviceFamilies { get; } Task CanPublishAsync(string pathOrUrl, CancellationToken ct); - Task PublishAsync(string pathOrUrl, DevCenterApplication? app, string? flightId, DirectoryInfo? inputDirectory, bool noCommit, float? packageRolloutPercentage, long uploadTimeout, IStorePackagedAPI storePackagedAPI, CancellationToken ct); + Task PublishAsync(string pathOrUrl, DevCenterApplication? app, string? flightId, DirectoryInfo? inputDirectory, bool noCommit, float? packageRolloutPercentage, string? priceId, long uploadTimeout, IStorePackagedAPI storePackagedAPI, CancellationToken ct); } } \ No newline at end of file diff --git a/MSStore.CLI/ProjectConfigurators/MSIXProjectPublisher.cs b/MSStore.CLI/ProjectConfigurators/MSIXProjectPublisher.cs index f8d6850..43e9590 100644 --- a/MSStore.CLI/ProjectConfigurators/MSIXProjectPublisher.cs +++ b/MSStore.CLI/ProjectConfigurators/MSIXProjectPublisher.cs @@ -94,7 +94,7 @@ public Task CanPublishAsync(string pathOrUrl, CancellationToken ct) return Task.FromResult(_appXManifestManager.GetAppId(appxManifest)); } - public async Task PublishAsync(string pathOrUrl, DevCenterApplication? app, string? flightId, DirectoryInfo? inputDirectory, bool noCommit, float? packageRolloutPercentage, long uploadTimeout, IStorePackagedAPI storePackagedAPI, CancellationToken ct) + public async Task PublishAsync(string pathOrUrl, DevCenterApplication? app, string? flightId, DirectoryInfo? inputDirectory, bool noCommit, float? packageRolloutPercentage, string? priceId, long uploadTimeout, IStorePackagedAPI storePackagedAPI, CancellationToken ct) { var msix = new FileInfo(pathOrUrl); @@ -131,7 +131,7 @@ public async Task PublishAsync(string pathOrUrl, DevCenterApplication? app, _logger.LogInformation("Trying to publish these {FileCount} files: {FileNames}", packageFiles.Count, string.Join(", ", packageFiles.Select(f => $"'{f.FullName}'"))); - return await storePackagedAPI.PublishAsync(_ansiConsole, _app, flightId, GetFirstSubmissionDataAsync, AllowTargetFutureDeviceFamilies, output, packageFiles, noCommit, packageRolloutPercentage, uploadTimeout, _browserLauncher, _consoleReader, _zipFileManager, _fileDownloader, _azureBlobManager, _environmentInformationService, _logger, ct); + return await storePackagedAPI.PublishAsync(_ansiConsole, _app, flightId, GetFirstSubmissionDataAsync, AllowTargetFutureDeviceFamilies, output, packageFiles, noCommit, packageRolloutPercentage, priceId, uploadTimeout, _browserLauncher, _consoleReader, _zipFileManager, _fileDownloader, _azureBlobManager, _environmentInformationService, _logger, ct); } private Task<(string Description, List Images)> GetFirstSubmissionDataAsync(string listingLanguage, CancellationToken ct) diff --git a/MSStore.CLI/ProjectConfigurators/PWAProjectConfigurator.cs b/MSStore.CLI/ProjectConfigurators/PWAProjectConfigurator.cs index ffa0aab..8fe44ef 100644 --- a/MSStore.CLI/ProjectConfigurators/PWAProjectConfigurator.cs +++ b/MSStore.CLI/ProjectConfigurators/PWAProjectConfigurator.cs @@ -317,7 +317,7 @@ await _pwaAppInfoManager.SaveAsync( return Task.FromResult((0, (DirectoryInfo?)new DirectoryInfo(pathOrUrl))); } - public async Task PublishAsync(string pathOrUrl, DevCenterApplication? app, string? flightId, DirectoryInfo? inputDirectory, bool noCommit, float? packageRolloutPercentage, long uploadTimeout, IStorePackagedAPI storePackagedAPI, CancellationToken ct) + public async Task PublishAsync(string pathOrUrl, DevCenterApplication? app, string? flightId, DirectoryInfo? inputDirectory, bool noCommit, float? packageRolloutPercentage, string? priceId, long uploadTimeout, IStorePackagedAPI storePackagedAPI, CancellationToken ct) { Uri? uri = GetUri(pathOrUrl); @@ -391,6 +391,7 @@ public async Task PublishAsync(string pathOrUrl, DevCenterApplication? app, packageFiles, noCommit, packageRolloutPercentage, + priceId, uploadTimeout, _browserLauncher, _consoleReader,