Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions MSStore.API/Packaged/Models/PriceIds.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Well-known values for <see cref="Pricing.PriceId"/>, and the rules for which
/// of them may be sent back to the Store submission API on an update.
/// </summary>
/// <remarks>
/// These live outside <see cref="Pricing"/> on purpose. That type is serialized with
/// <see cref="System.Text.Json.Serialization.JsonIgnoreCondition.Never"/>, so any member
/// added to it would show up in the request body.
/// </remarks>
public static class PriceIds
{
/// <summary>
/// Sentinel meaning "the price tier is not set; use the base price for the app".
/// It is a legal value inside <see cref="Pricing.MarketSpecificPricings"/>, but the
/// API also returns it as the <em>base</em> price of products managed by the newer
/// per-market pricing model - where it cannot be sent back. Updating a submission
/// with it fails with <c>'Base' is not a valid PriceId for base price.</c>
/// </summary>
public const string Base = "Base";

/// <summary>The app is free.</summary>
public const string Free = "Free";

/// <summary>The app is not available in the given market.</summary>
public const string NotAvailable = "NotAvailable";

private const string TierPrefix = "Tier";
Comment thread
azchohfi marked this conversation as resolved.

/// <summary>
/// Whether a price id read from a submission can be sent back unchanged on update.
/// </summary>
/// <remarks>
/// Everything except <see cref="Base"/> (and a missing value) round-trips.
/// <para>
/// 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 <c>null</c> price id, a pricing
/// object with the property removed, and an empty pricing object all answer
/// <c>200 OK</c> and silently turn the product free. Omitting the property is therefore
/// not a way to leave the price untouched - there is no such way.
/// </para>
/// </remarks>
/// <param name="priceId">The price id to check.</param>
/// <returns><c>true</c> when <paramref name="priceId"/> is safe to send back.</returns>
public static bool IsRoundTrippable(string? priceId) =>
!string.IsNullOrWhiteSpace(priceId) &&
!string.Equals(priceId.Trim(), Base, StringComparison.OrdinalIgnoreCase);

/// <summary>
/// Validates a user supplied price id and converts it to the casing the API expects.
/// </summary>
/// <remarks>
/// Tier numbers are deliberately not range checked. The documented ranges
/// (<c>Tier2</c>-<c>Tier96</c> and <c>Tier1012</c>-<c>Tier1424</c>) describe what a
/// dashboard offers, not what the API accepts, and <c>isAdvancedPricingModel</c> 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.
/// </remarks>
/// <param name="priceId">The price id to normalize.</param>
/// <param name="normalized">The normalized price id, when valid.</param>
/// <returns><c>true</c> when <paramref name="priceId"/> is a value the API accepts.</returns>
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;
}
}
}
25 changes: 22 additions & 3 deletions MSStore.CLI.UnitTests/BaseCommandLineTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,21 @@ protected static void AssertBasedOnTestDataProjectSubPath(string[] testDataProje
}
}

/// <summary>
/// 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.
/// </summary>
/// <param name="text">The captured console output.</param>
/// <returns>The text without styling, with every run of whitespace collapsed to one space.</returns>
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<string> _temporaryPayloadFiles = [];

/// <summary>
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading