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 - 400Pricing 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