Skip to content
Merged
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
9 changes: 6 additions & 3 deletions .github/workflows/dotnet.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# This workflow will build a .NET project
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net

name: Run Unittests on PR
name: Run tests on PR

on:
push:
Expand All @@ -26,6 +26,9 @@ jobs:
- name: Build
run: dotnet build --no-restore
working-directory: ./src
- name: Test
run: dotnet test --no-build --verbosity normal
- name: Run unit tests
run: dotnet test --no-build --verbosity normal --minimum-expected-tests 1 -- --filter "TestCategory!=Integration" --ignore-exit-code 8
working-directory: ./src
- name: Run integration tests
run: dotnet test --no-build --verbosity normal --minimum-expected-tests 1 -- --filter "TestCategory=Integration" --ignore-exit-code 8
working-directory: ./src
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

namespace PackageUploader.IntegrationTest.Fixtures;

/// <summary>Creates tiny, self-deleting synthetic package files for upload-path tests (not valid game content).</summary>
internal sealed class SyntheticPackageFile : IDisposable
{
public string Path { get; }

public long SizeInBytes { get; }

private SyntheticPackageFile(string path, long sizeInBytes)
{
Path = path;
SizeInBytes = sizeInBytes;
}

public static SyntheticPackageFile Create(long sizeInBytes = 64 * 1024, string extension = ".xvc", int seed = 1)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(sizeInBytes);

var path = System.IO.Path.Combine(
System.IO.Path.GetTempPath(),
$"pkguploader-it-{Guid.NewGuid():N}{extension}");

var random = new Random(seed);
const int bufferSize = 8 * 1024;
var buffer = new byte[bufferSize];

using (var stream = new FileStream(path, FileMode.CreateNew, FileAccess.Write, FileShare.None))
{
long remaining = sizeInBytes;
while (remaining > 0)
{
int chunk = (int)Math.Min(bufferSize, remaining);
random.NextBytes(buffer.AsSpan(0, chunk));
stream.Write(buffer, 0, chunk);
remaining -= chunk;
}
}

return new SyntheticPackageFile(path, sizeInBytes);
}

public void Dispose()
{
try
{
if (File.Exists(Path))
{
File.Delete(Path);
}
}
catch (Exception)
{
// Best-effort cleanup; a leaked temp file must never fail a test.
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using PackageUploader.ClientApi.Client.Ingestion.TokenProvider;
using PackageUploader.ClientApi.Client.Ingestion.TokenProvider.Models;

namespace PackageUploader.IntegrationTest.Infrastructure;

/// <summary>Test <see cref="IAccessTokenProvider"/> that returns a static fake token so the mock suite needs no real credentials.</summary>
internal sealed class FakeAccessTokenProvider : IAccessTokenProvider
{
public const string FakeToken = "fake-integration-test-token";

public Task<IngestionAccessToken> GetTokenAsync(CancellationToken ct) =>
Task.FromResult(new IngestionAccessToken
{
AccessToken = FakeToken,
ExpiresOn = DateTimeOffset.UtcNow.AddHours(1),
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace PackageUploader.IntegrationTest.Infrastructure;

/// <summary>Base class for integration tests; applies the <c>Integration</c> category and provides a host factory.</summary>
[TestCategory(Category)]
public abstract class IntegrationTestBase
{
public const string Category = "Integration";

private protected static PackageUploaderTestHost CreateHost(
Action<MockHttpMessageHandler>? configureIngestion = null) => new(configureIngestion);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;

namespace PackageUploader.IntegrationTest.Infrastructure;

/// <summary>In-process HTTP handler that returns scripted responses and records requests, backing the mock integration suite.</summary>
internal sealed class MockHttpMessageHandler : HttpMessageHandler
{
private readonly List<Responder> _responders = [];
private readonly List<RecordedRequest> _received = [];
private readonly Lock _receivedLock = new();

public IReadOnlyList<RecordedRequest> ReceivedRequests
{
get
{
lock (_receivedLock)
{
return _received.ToArray();
}
}
}

public MockHttpMessageHandler When(HttpMethod method, string pathContains,
Func<HttpRequestMessage, HttpResponseMessage> respond)
{
ArgumentNullException.ThrowIfNull(method);
ArgumentNullException.ThrowIfNull(pathContains);
ArgumentNullException.ThrowIfNull(respond);

_responders.Add(new Responder(method, pathContains, respond));
return this;
}

public MockHttpMessageHandler WhenJson(HttpMethod method, string pathContains, string json,
HttpStatusCode status = HttpStatusCode.OK) =>
When(method, pathContains, _ => new HttpResponseMessage(status)
{
Content = new StringContent(json, Encoding.UTF8, "application/json"),
});

protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
CancellationToken cancellationToken)
{
string? body = request.Content is null
? null
: await request.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);

lock (_receivedLock)
{
_received.Add(new RecordedRequest(
request.Method,
request.RequestUri!,
CloneHeaders(request.Headers),
body));
}

var responder = _responders.FirstOrDefault(r => r.Matches(request));
if (responder is null)
{
// Return a non-transient 4xx so a missing stub fails fast: the Ingestion pipeline's Polly
// policy retries on >=500, which would otherwise turn a missing stub into slow retries.
return new HttpResponseMessage(HttpStatusCode.BadRequest)
{
RequestMessage = request,
Content = new StringContent(
$"No mock responder registered for {request.Method} {request.RequestUri}",
Encoding.UTF8, "text/plain"),
};
}

var response = responder.Respond(request);
response.RequestMessage ??= request;
return response;
}

private static IReadOnlyDictionary<string, string> CloneHeaders(HttpRequestHeaders headers)
{
var clone = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var header in headers)
{
clone[header.Key] = string.Join(", ", header.Value);
}
return clone;
}

private sealed class Responder(HttpMethod method, string pathContains,
Func<HttpRequestMessage, HttpResponseMessage> respond)
{
public bool Matches(HttpRequestMessage request) =>
request.Method == method &&
request.RequestUri is not null &&
request.RequestUri.PathAndQuery.Contains(pathContains, StringComparison.OrdinalIgnoreCase);

public HttpResponseMessage Respond(HttpRequestMessage request) => respond(request);
}
}

/// <summary>Snapshot of a request observed by <see cref="MockHttpMessageHandler"/>.</summary>
internal sealed record RecordedRequest(
HttpMethod Method,
Uri Uri,
IReadOnlyDictionary<string, string> Headers,
string? Body);
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using PackageUploader.ClientApi;
using PackageUploader.ClientApi.Client.Ingestion.TokenProvider;

namespace PackageUploader.IntegrationTest.Infrastructure;

/// <summary>Composes the real <see cref="IPackageUploaderService"/> with the Ingestion network handler and access-token provider replaced by test doubles.</summary>
internal sealed class PackageUploaderTestHost : IDisposable
{
private const string IngestionHttpClientName = "IIngestionHttpClient";

private readonly ServiceProvider _provider;
private readonly IServiceScope _scope;

public MockHttpMessageHandler IngestionHandler { get; }

public IPackageUploaderService Service { get; }

public PackageUploaderTestHost(
Action<MockHttpMessageHandler>? configureIngestion = null,
string ingestionBaseAddress = "https://ingestion.test.local/")
{
IngestionHandler = new MockHttpMessageHandler();
configureIngestion?.Invoke(IngestionHandler);

var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["IngestionConfig:BaseAddress"] = ingestionBaseAddress,
})
.Build();

var services = new ServiceCollection();
services.AddSingleton<IConfiguration>(configuration);
services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance));

services.AddPackageUploaderService(IngestionExtensions.AuthenticationMethod.Default);

services.RemoveAll<IAccessTokenProvider>();
services.AddScoped<IAccessTokenProvider, FakeAccessTokenProvider>();

services.AddHttpClient(IngestionHttpClientName)
.ConfigurePrimaryHttpMessageHandler(() => IngestionHandler);

// IPackageUploaderService and the Ingestion auth handler are scoped; resolve them from an
// explicit scope (with scope validation on) rather than the root provider.
_provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true });
_scope = _provider.CreateScope();
Service = _scope.ServiceProvider.GetRequiredService<IPackageUploaderService>();
}

public void Dispose()
{
_scope.Dispose();
_provider.Dispose();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<Project Sdk="MSTest.Sdk/4.2.2">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<IsPackable>false</IsPackable>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
<EnableMicrosoftTestingExtensionsCodeCoverage>true</EnableMicrosoftTestingExtensionsCodeCoverage>
<EnableMicrosoftTestingExtensionsTrxReport>true</EnableMicrosoftTestingExtensionsTrxReport>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="coverlet.collector" Version="10.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\PackageUploader.ClientApi\PackageUploader.ClientApi.csproj" />
<ProjectReference Include="..\PackageUploader.Application\PackageUploader.Application.csproj" />
</ItemGroup>

</Project>
34 changes: 34 additions & 0 deletions src/PackageUploader.IntegrationTest/SmokeTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using Microsoft.VisualStudio.TestTools.UnitTesting;
using PackageUploader.IntegrationTest.Infrastructure;
using System.Net.Http;

namespace PackageUploader.IntegrationTest;

/// <summary>
/// Smoke test that validates the integration project is discovered, builds, and that the mock
/// harness routes a public service call through the real pipeline to the mock handler.
/// </summary>
[TestClass]
public sealed class SmokeTest : IntegrationTestBase
{
[TestMethod]
public async Task TestHost_RoutesProductLookup_ThroughMockHandlerWithFakeAuth()
{
using var host = CreateHost(mock =>
mock.WhenJson(HttpMethod.Get, "/products/", "{\"id\":\"smoke-test-product\"}"));

var product = await host.Service.GetProductByProductIdAsync("smoke-test-product", TestContext.CancellationToken);

Assert.IsNotNull(product);
Assert.AreEqual(1, host.IngestionHandler.ReceivedRequests.Count);

var request = host.IngestionHandler.ReceivedRequests[0];
Assert.IsTrue(request.Headers.ContainsKey("Authorization"));
StringAssert.Contains(request.Headers["Authorization"], FakeAccessTokenProvider.FakeToken);
}

public TestContext TestContext { get; set; } = null!;
}
Loading
Loading