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
180 changes: 180 additions & 0 deletions apps/pwabuilder-microsoft-store.Tests/CosmosDbServiceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
using Microsoft.Azure.Cosmos;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;
using PWABuilder.MicrosoftStore.Models;
using System.Net;
using Xunit;

namespace PWABuilder.MicrosoftStore.Tests;

public sealed class CosmosDbServiceTests
{
/// <summary>
/// Verifies cross-partition query pagination, exact matching, and a productId-only patch for every returned record.
/// </summary>
[Fact]
public async Task UpdateProductIdAsync_WhenMatchesSpanPages_PatchesEveryDocumentUsingItsIdPartition()
{
using var cancellation = new CancellationTokenSource();
var container = new Mock<Container>();
var iterator = CreateIterator(["record1", "record2"], ["record3"]);
QueryDefinition? query = null;
container.Setup(c => c.GetItemQueryIterator<string>(It.IsAny<QueryDefinition>(), null, null))
.Callback<QueryDefinition, string, QueryRequestOptions>((definition, _, _) => query = definition)
.Returns(iterator.Object);
var patchedIds = new List<string>();
container.Setup(c => c.PatchItemAsync<PwaBuilderMsStorePackage>(
It.IsAny<string>(), It.IsAny<PartitionKey>(), It.IsAny<IReadOnlyList<PatchOperation>>(),
It.IsAny<PatchItemRequestOptions>(), cancellation.Token))
.Callback<string, PartitionKey, IReadOnlyList<PatchOperation>, PatchItemRequestOptions, CancellationToken>(
(id, partition, patches, options, _) =>
{
patchedIds.Add(id);
Assert.Equal(new PartitionKey(id), partition);
var patch = Assert.Single(patches);
Assert.Equal(PatchOperationType.Set, patch.OperationType);
Assert.Equal("/productId", patch.Path);
Assert.Equal("9NHKJB6LPPTV", Assert.IsAssignableFrom<PatchOperation<string>>(patch).Value);
Assert.False(options.EnableContentResponseOnWrite);
})
.ReturnsAsync(Mock.Of<ItemResponse<PwaBuilderMsStorePackage>>());
var store = new CosmosDbService(container.Object, NullLogger<CosmosDbService>.Instance);

var count = await store.UpdateProductIdAsync("42541BitShuva.ChavahMessianicRadio", "9NHKJB6LPPTV", cancellation.Token);

Assert.Equal(3, count);
Assert.Equal(["record1", "record2", "record3"], patchedIds);
Assert.NotNull(query);
Assert.Contains("STRINGEQUALS(c.packageId, @packageId, true)", query.QueryText);
Assert.Contains("NOT IS_DEFINED(c.productId) OR IS_NULL(c.productId) OR c.productId != @productId", query.QueryText);
Assert.Contains(query.GetQueryParameters(), p => p.Name == "@packageId" && Equals(p.Value, "42541BitShuva.ChavahMessianicRadio"));
Assert.Contains(query.GetQueryParameters(), p => p.Name == "@productId" && Equals(p.Value, "9NHKJB6LPPTV"));
iterator.Verify(i => i.ReadNextAsync(cancellation.Token), Times.Exactly(2));
}

/// <summary>
/// Verifies no writes occur when the query finds no new or changed matches.
/// </summary>
[Fact]
public async Task UpdateProductIdAsync_WhenNoDocumentsNeedUpdating_DoesNotWrite()
{
var container = new Mock<Container>(MockBehavior.Strict);
container.Setup(c => c.GetItemQueryIterator<string>(It.IsAny<QueryDefinition>(), null, null))
.Returns(CreateIterator([]).Object);
var store = new CosmosDbService(container.Object, NullLogger<CosmosDbService>.Instance);

Assert.Equal(0, await store.UpdateProductIdAsync("Company.App", "Product1", CancellationToken.None));
}

/// <summary>
/// Verifies TTL expiry between the query and patch does not prevent other matches being updated.
/// </summary>
[Fact]
public async Task UpdateProductIdAsync_WhenDocumentExpires_ContinuesToRemainingMatches()
{
var container = new Mock<Container>();
container.Setup(c => c.GetItemQueryIterator<string>(It.IsAny<QueryDefinition>(), null, null))
.Returns(CreateIterator(["expired", "present"]).Object);
container.Setup(c => c.PatchItemAsync<PwaBuilderMsStorePackage>(
"expired", It.IsAny<PartitionKey>(), It.IsAny<IReadOnlyList<PatchOperation>>(),
It.IsAny<PatchItemRequestOptions>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new CosmosException("Expired", HttpStatusCode.NotFound, 0, "test", 0));
container.Setup(c => c.PatchItemAsync<PwaBuilderMsStorePackage>(
"present", new PartitionKey("present"), It.IsAny<IReadOnlyList<PatchOperation>>(),
It.IsAny<PatchItemRequestOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Mock.Of<ItemResponse<PwaBuilderMsStorePackage>>());
var store = new CosmosDbService(container.Object, NullLogger<CosmosDbService>.Instance);

Assert.Equal(1, await store.UpdateProductIdAsync("Company.App", "Product1", CancellationToken.None));
}

/// <summary>
/// Verifies non-expiry database errors propagate to the worker's error logging.
/// </summary>
[Fact]
public async Task UpdateProductIdAsync_WhenCosmosFails_PropagatesFailure()
{
var container = new Mock<Container>();
container.Setup(c => c.GetItemQueryIterator<string>(It.IsAny<QueryDefinition>(), null, null))
.Returns(CreateIterator(["record1"]).Object);
container.Setup(c => c.PatchItemAsync<PwaBuilderMsStorePackage>(
It.IsAny<string>(), It.IsAny<PartitionKey>(), It.IsAny<IReadOnlyList<PatchOperation>>(),
It.IsAny<PatchItemRequestOptions>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new CosmosException("Forbidden", HttpStatusCode.Forbidden, 0, "test", 0));
var store = new CosmosDbService(container.Object, NullLogger<CosmosDbService>.Instance);

await Assert.ThrowsAsync<CosmosException>(() =>
store.UpdateProductIdAsync("Company.App", "Product1", CancellationToken.None));
}

/// <summary>
/// Verifies shutdown interrupts document updates before the next write.
/// </summary>
[Fact]
public async Task UpdateProductIdAsync_WhenCancelled_DoesNotPatch()
{
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
var container = new Mock<Container>(MockBehavior.Strict);
container.Setup(c => c.GetItemQueryIterator<string>(It.IsAny<QueryDefinition>(), null, null))
.Returns(CreateIterator(["record1"]).Object);
var store = new CosmosDbService(container.Object, NullLogger<CosmosDbService>.Instance);

await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
store.UpdateProductIdAsync("Company.App", "Product1", cancellation.Token));
}

/// <summary>
/// Verifies an unconfigured package store cannot report a successful update.
/// </summary>
[Fact]
public async Task UpdateProductIdAsync_WhenCosmosIsUnconfigured_Throws()
{
var settings = Options.Create(new AppSettings { ImageGeneratorApiUrl = new Uri("https://example.com") });
var store = new CosmosDbService(settings, NullLogger<CosmosDbService>.Instance);

Assert.False(store.IsEnabled);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
store.UpdateProductIdAsync("Company.App", "Product1", CancellationToken.None));
}

/// <summary>
/// Verifies legacy documents remain readable and the new field follows Cosmos's camel-case serialization.
/// </summary>
[Fact]
public void ProductId_WhenSerializingPackage_IsOptionalAndCamelCased()
{
var settings = new JsonSerializerSettings { ContractResolver = new CamelCasePropertyNamesContractResolver() };
var package = JsonConvert.DeserializeObject<PwaBuilderMsStorePackage>(
"""{"id":"record1","packageId":"Company.App"}""", settings);
Assert.NotNull(package);
Assert.Null(package.ProductId);

package.ProductId = "Product1";
var json = JsonConvert.SerializeObject(package, settings);

Assert.Contains("\"productId\":\"Product1\"", json);
Assert.Contains("\"packageId\":\"Company.App\"", json);
}

/// <summary>
/// Creates a paged Cosmos query result without requiring an emulator.
/// </summary>
private static Mock<FeedIterator<string>> CreateIterator(params string[][] pages)
{
var iterator = new Mock<FeedIterator<string>>();
var index = 0;
iterator.SetupGet(i => i.HasMoreResults).Returns(() => index < pages.Length);
iterator.Setup(i => i.ReadNextAsync(It.IsAny<CancellationToken>())).Returns(() =>
{
var page = pages[index++];
var response = new Mock<FeedResponse<string>>();
response.Setup(r => r.GetEnumerator()).Returns(() => ((IEnumerable<string>)page).GetEnumerator());
return Task.FromResult(response.Object);
});
return iterator;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
Expand Down
210 changes: 210 additions & 0 deletions apps/pwabuilder-microsoft-store.Tests/SfEdgeClientTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
using PWABuilder.MicrosoftStore.Services;
using System.Net;
using System.Text.Json;
using Xunit;

namespace PWABuilder.MicrosoftStore.Tests;

public sealed class SfEdgeClientTests
{
/// <summary>
/// Verifies all pages, including a partial final page, are read with duplicate products removed.
/// </summary>
[Fact]
public async Task GetPwaProductIdsAsync_WhenCatalogHasMultiplePages_ReadsThroughEmptyPage()
{
var queries = new List<string>();
var factory = new SfEdgeTestHttpClientFactory((request, _) =>
{
queries.Add(request.RequestUri!.Query);
return Task.FromResult(queries.Count switch
{
1 => SfEdgeTestHttpClientFactory.Search(Enumerable.Range(0, 15).Select(i => $"Product{i}").ToArray()),
2 => SfEdgeTestHttpClientFactory.Search("product14", "Product15"),
3 => SfEdgeTestHttpClientFactory.Search(),
_ => throw new InvalidOperationException("Unexpected search request.")
});
});
var client = new SfEdgeClient(factory);

var products = await ReadProductsAsync(client);

Assert.Equal(Enumerable.Range(0, 16).Select(i => $"Product{i}"), products);
Assert.Contains("pageSize=15&skipItems=0&", queries[0]);
Assert.Contains("skipItems=15&", queries[1]);
Assert.Contains("skipItems=17&", queries[2]);
Assert.All(queries, query => Assert.Contains("tags=AppExtension-microsoft.store.edgePWA", query));
}

/// <summary>
/// Verifies an empty catalog does not trigger another request.
/// </summary>
[Theory]
[InlineData("""{"Payload":{"Products":[],"TotalItems":0,"PageSize":0}}""")]
[InlineData("""{"Payload":{"Cards":[],"TotalItems":0,"PageSize":0}}""")]
public async Task GetPwaProductIdsAsync_WhenCatalogIsEmpty_ReturnsNoProducts(string json)
{
var requests = 0;
var client = new SfEdgeClient(new SfEdgeTestHttpClientFactory((_, _) =>
{
requests++;
return Task.FromResult(SfEdgeTestHttpClientFactory.Json(json));
}));

Assert.Empty(await ReadProductsAsync(client));
Assert.Equal(1, requests);
}

/// <summary>
/// Verifies malformed search responses are failures, not successful empty scans.
/// </summary>
[Theory]
[InlineData("{}")]
[InlineData("""{"Payload":{}}""")]
[InlineData("""{"Payload":{"Cards":null}}""")]
[InlineData("""{"Payload":{"Cards":[{}]}}""")]
[InlineData("""{"Payload":{"Cards":[{"ProductId":null}]}}""")]
[InlineData("""{"Payload":{"Cards":[{"ProductId":" "}]}}""")]
[InlineData("""{"Payload":{"Products":[]}}""")]
[InlineData("""{"Payload":{"Products":[],"TotalItems":20}}""")]
[InlineData("""{"Payload":{"Products":[{"ProductId":"Product1"}],"TotalItems":1}}""")]
public async Task GetPwaProductIdsAsync_WhenResponseIsInvalid_ThrowsJsonException(string json)
{
var client = new SfEdgeClient(new SfEdgeTestHttpClientFactory((_, _) =>
Task.FromResult(SfEdgeTestHttpClientFactory.Json(json))));

await Assert.ThrowsAsync<JsonException>(() => ReadProductsAsync(client));
}

/// <summary>
/// Verifies an API ignoring skipItems cannot cause an infinite scan.
/// </summary>
[Fact]
public async Task GetPwaProductIdsAsync_WhenPageRepeats_ThrowsJsonException()
{
var requests = 0;
var client = new SfEdgeClient(new SfEdgeTestHttpClientFactory((_, _) =>
{
requests++;
return Task.FromResult(SfEdgeTestHttpClientFactory.Search("Product1"));
}));

await Assert.ThrowsAsync<JsonException>(() => ReadProductsAsync(client));
Assert.Equal(2, requests);
}

/// <summary>
/// Verifies only the requested product's details are used, with all distinct identity names extracted.
/// </summary>
[Fact]
public async Task GetPackageIdsAsync_WhenPdpContainsMultipleEnvelopes_ReadsMatchingProductFamilies()
{
var client = new SfEdgeClient(new SfEdgeTestHttpClientFactory((request, _) =>
{
Assert.StartsWith("/v9.0/pages/pdp?", request.RequestUri!.PathAndQuery);
Assert.Contains("productId=9NHKJB6LPPTV&", request.RequestUri.Query);
return Task.FromResult(SfEdgeTestHttpClientFactory.Json("""
[
{"Payload":{"PageObject":{"PackageFamilyNames":["Wrong.Nested_hash"]}}},
{"Payload":{"ProductId":"RELATED","PackageFamilyNames":["Wrong.Related_hash"]}},
{"Payload":{"ProductId":"9NHKJB6LPPTV","PackageFamilyNames":[
"42541BitShuva.ChavahMessianicRadio_y3m7a4hh6j3hy",
"42541BitShuva.ChavahMessianicRadio_otherhash",
"Company.App_hash",
"company.app_hash",
"Company.App2_hash"
]}}
]
"""));
}));

var packageIds = await client.GetPackageIdsAsync("9NHKJB6LPPTV", CancellationToken.None);

Assert.Equal(3, packageIds.Count);
Assert.Contains("42541BitShuva.ChavahMessianicRadio", packageIds);
Assert.Contains("Company.App", packageIds);
Assert.Contains("Company.App2", packageIds);
}

/// <summary>
/// Verifies empty package-family arrays are supported.
/// </summary>
[Fact]
public async Task GetPackageIdsAsync_WhenProductHasNoFamilies_ReturnsEmptyCollection()
{
var client = new SfEdgeClient(new SfEdgeTestHttpClientFactory((_, _) =>
Task.FromResult(SfEdgeTestHttpClientFactory.Product("Product1"))));

Assert.Empty(await client.GetPackageIdsAsync("Product1", CancellationToken.None));
}

/// <summary>
/// Verifies invalid or missing product details cannot produce guessed package matches.
/// </summary>
[Theory]
[InlineData("{}")]
[InlineData("[]")]
[InlineData("""[{"Payload":{"ProductId":"Wrong","PackageFamilyNames":["Company.App_hash"]}}]""")]
[InlineData("""[{"Payload":{"ProductId":"Product1"}}]""")]
[InlineData("""[{"Payload":{"ProductId":"Product1","PackageFamilyNames":null}}]""")]
[InlineData("""[{"Payload":{"ProductId":"Product1","PackageFamilyNames":[null]}}]""")]
[InlineData("""[{"Payload":{"ProductId":"Product1","PackageFamilyNames":["Company.App"]}}]""")]
[InlineData("""[{"Payload":{"ProductId":"Product1","PackageFamilyNames":["_hash"]}}]""")]
[InlineData("""[{"Payload":{"ProductId":"Product1","PackageFamilyNames":["Company.App_"]}}]""")]
[InlineData("""[{"Payload":{"ProductId":"Product1","PackageFamilyNames":["Company_App_hash"]}}]""")]
public async Task GetPackageIdsAsync_WhenResponseIsInvalid_ThrowsJsonException(string json)
{
var client = new SfEdgeClient(new SfEdgeTestHttpClientFactory((_, _) =>
Task.FromResult(SfEdgeTestHttpClientFactory.Json(json))));

await Assert.ThrowsAsync<JsonException>(() => client.GetPackageIdsAsync("Product1", CancellationToken.None));
}

/// <summary>
/// Verifies HTTP failures propagate rather than being interpreted as an empty catalog.
/// </summary>
[Fact]
public async Task GetPwaProductIdsAsync_WhenApiFails_ThrowsHttpRequestException()
{
var client = new SfEdgeClient(new SfEdgeTestHttpClientFactory((_, _) =>
Task.FromResult(SfEdgeTestHttpClientFactory.Json("{}", HttpStatusCode.ServiceUnavailable))));

await Assert.ThrowsAsync<HttpRequestException>(() => ReadProductsAsync(client));
}

/// <summary>
/// Verifies cancellation is passed to an in-flight HTTP request.
/// </summary>
[Fact]
public async Task GetPackageIdsAsync_WhenCancelled_CancelsHttpRequest()
{
using var cancellation = new CancellationTokenSource();
var requested = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var client = new SfEdgeClient(new SfEdgeTestHttpClientFactory(async (_, token) =>
{
requested.SetResult();
await Task.Delay(Timeout.InfiniteTimeSpan, token);
throw new InvalidOperationException("A cancelled request must not finish.");
}));

var request = client.GetPackageIdsAsync("Product1", cancellation.Token);
await requested.Task.WaitAsync(TimeSpan.FromSeconds(5));
cancellation.Cancel();

await Assert.ThrowsAnyAsync<OperationCanceledException>(() => request);
}

/// <summary>
/// Materializes the asynchronous catalog for assertions.
/// </summary>
private static async Task<List<string>> ReadProductsAsync(SfEdgeClient client)
{
var products = new List<string>();
await foreach (var productId in client.GetPwaProductIdsAsync(CancellationToken.None))
{
products.Add(productId);
}

return products;
}
}
Loading
Loading