diff --git a/src/PackageUploader.IntegrationTest/Infrastructure/IntegrationTestBase.cs b/src/PackageUploader.IntegrationTest/Infrastructure/IntegrationTestBase.cs index bdfa8573..bbeaaf90 100644 --- a/src/PackageUploader.IntegrationTest/Infrastructure/IntegrationTestBase.cs +++ b/src/PackageUploader.IntegrationTest/Infrastructure/IntegrationTestBase.cs @@ -11,6 +11,6 @@ public abstract class IntegrationTestBase { public const string Category = "Integration"; - private protected static PackageUploaderTestHost CreateHost( - Action? configureIngestion = null) => new(configureIngestion); + /// Creates a host wired to live WireMock.Net fakes of the Ingestion API and XFUS. + private protected static MockServerTestHost CreateMockServerHost() => new(); } diff --git a/src/PackageUploader.IntegrationTest/Infrastructure/MockHttpMessageHandler.cs b/src/PackageUploader.IntegrationTest/Infrastructure/MockHttpMessageHandler.cs deleted file mode 100644 index 18defbf6..00000000 --- a/src/PackageUploader.IntegrationTest/Infrastructure/MockHttpMessageHandler.cs +++ /dev/null @@ -1,109 +0,0 @@ -// 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; - -/// In-process HTTP handler that returns scripted responses and records requests, backing the mock integration suite. -internal sealed class MockHttpMessageHandler : HttpMessageHandler -{ - private readonly List _responders = []; - private readonly List _received = []; - private readonly Lock _receivedLock = new(); - - public IReadOnlyList ReceivedRequests - { - get - { - lock (_receivedLock) - { - return _received.ToArray(); - } - } - } - - public MockHttpMessageHandler When(HttpMethod method, string pathContains, - Func 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 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 CloneHeaders(HttpRequestHeaders headers) - { - var clone = new Dictionary(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 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); - } -} - -/// Snapshot of a request observed by . -internal sealed record RecordedRequest( - HttpMethod Method, - Uri Uri, - IReadOnlyDictionary Headers, - string? Body); diff --git a/src/PackageUploader.IntegrationTest/Infrastructure/MockServerTestHost.cs b/src/PackageUploader.IntegrationTest/Infrastructure/MockServerTestHost.cs new file mode 100644 index 00000000..96cb6796 --- /dev/null +++ b/src/PackageUploader.IntegrationTest/Infrastructure/MockServerTestHost.cs @@ -0,0 +1,83 @@ +// 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; +using PackageUploader.IntegrationTest.Infrastructure.Mocks; + +namespace PackageUploader.IntegrationTest.Infrastructure; + +/// +/// Composes the real wired to in-memory fakes of the Ingestion +/// API and XFUS. The fakes are plugged in as the primary +/// of the Ingestion and XFUS named clients, so the real pipeline (auth handler, Polly policies, +/// serialization, mappers) runs against deterministic in-memory responses with no network and no +/// third-party dependency. Authentication uses . +/// +internal sealed class MockServerTestHost : IDisposable +{ + // Logical names HttpClientFactory assigns to the Ingestion and XFUS clients; configuring the + // primary handler by these names overrides the production ones (real network) with the fakes. + private const string IngestionHttpClientName = "IIngestionHttpClient"; + private const string XfusHttpClientName = "xfus"; + + private readonly ServiceProvider _provider; + private readonly IServiceScope _scope; + + /// The fake Ingestion API. Configure stubs before exercising the service. + public IngestionMockHandler Ingestion { get; } + + /// The fake XFUS upload service. Configure stubs before exercising the service. + public XfusMockHandler Xfus { get; } + + /// The fully composed, public service under test, wired to the fakes. + public IPackageUploaderService Service { get; } + + public MockServerTestHost() + { + Ingestion = new IngestionMockHandler(); + Xfus = new XfusMockHandler(); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + // Host is irrelevant (the handler is overridden) but must be a valid absolute URI. + ["IngestionConfig:BaseAddress"] = "http://ingestion.local/", + // Keep retry/timeout fast so retry-scenario tests don't sleep on real backoffs. + ["IngestionConfig:MedianFirstRetryDelayMs"] = "1", + ["IngestionConfig:RetryCount"] = "3", + }) + .Build(); + + var services = new ServiceCollection(); + services.AddSingleton(configuration); + services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance)); + + services.AddPackageUploaderService(IngestionExtensions.AuthenticationMethod.Default); + + services.RemoveAll(); + services.AddScoped(); + + // Override the primary handlers with the in-memory fakes (Polly + auth handlers still run). + services.AddHttpClient(IngestionHttpClientName).ConfigurePrimaryHttpMessageHandler(() => Ingestion); + services.AddHttpClient(XfusHttpClientName).ConfigurePrimaryHttpMessageHandler(() => Xfus); + + _provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true }); + _scope = _provider.CreateScope(); + Service = _scope.ServiceProvider.GetRequiredService(); + } + + /// The XFUS upload domain to embed in a stubbed package response (host is irrelevant). + public string XfusUploadDomain => "http://xfus.local"; + + public void Dispose() + { + _scope.Dispose(); + _provider.Dispose(); + } +} diff --git a/src/PackageUploader.IntegrationTest/Infrastructure/Mocks/IngestionMockHandler.cs b/src/PackageUploader.IntegrationTest/Infrastructure/Mocks/IngestionMockHandler.cs new file mode 100644 index 00000000..33330450 --- /dev/null +++ b/src/PackageUploader.IntegrationTest/Infrastructure/Mocks/IngestionMockHandler.cs @@ -0,0 +1,217 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using System.Net.Http; + +namespace PackageUploader.IntegrationTest.Infrastructure.Mocks; + +/// +/// First-party (BCL-only) in-memory fake of the Partner Center Ingestion API, plugged in as the +/// primary handler of the Ingestion . Exposes fluent stubs for the endpoints +/// PackageUploader uses, with configurable success / error / retry / polling scenarios. +/// +internal sealed class IngestionMockHandler : StubHttpMessageHandler +{ + // ---- GetProduct: GET /products/{id} ---- + + public IngestionMockHandler StubGetProduct(string productId, ResponseScenario scenario = ResponseScenario.Success) + { + if (scenario != ResponseScenario.Success) + { + On(HttpMethod.Get, $"/products/{productId}", () => Status(StatusFor(scenario))); + return this; + } + + On(HttpMethod.Get, $"/products/{productId}", () => Json(new + { + resourceType = "AzureGameProduct", + name = $"Test Product {productId}", + id = productId, + externalIds = new[] { new { type = "StoreId", value = "9TESTBIGID000" } }, + isModularPublishing = true, + })); + return this; + } + + // ---- GetBranches: GET /products/{id}/branches/getByModule(module=Package) (+ flights) ---- + + public IngestionMockHandler StubGetPackageBranches( + string productId, + params (string FriendlyName, string CurrentDraftInstanceId)[] branches) + { + var values = branches.Select(b => new + { + resourceType = "Branch", + friendlyName = b.FriendlyName, + type = "Main", + module = "Package", + currentDraftInstanceId = b.CurrentDraftInstanceId, + }).ToArray(); + + On(HttpMethod.Get, $"/products/{productId}/branches/getByModule*", () => Json(new { value = values })); + // GetPackageBranchesAsync enumerates flights first; stub them as empty. + On(HttpMethod.Get, $"/products/{productId}/flights", () => Json(new { value = Array.Empty() })); + return this; + } + + // ---- CreatePackageRequest: POST /products/{id}/packages ---- + + public IngestionMockHandler StubCreatePackage( + string productId, + string packageId, + string fileName = "test.xvc", + string? xfusUploadDomain = null, + string? xfusId = null, + string xfusTenant = "DCE", + string xfusToken = "fake-xfus-token") + { + // Use null (not an empty object) for absent upload info so the field is omitted entirely; an + // empty object would deserialize into a non-null upload info with a null XfusId and crash the + // client's Guid mapping. + object? uploadInfo = xfusUploadDomain is null + ? null + : new + { + fileName, + xfusId = xfusId ?? Guid.NewGuid().ToString(), + token = xfusToken, + uploadDomain = xfusUploadDomain, + xfusTenant, + }; + + On(HttpMethod.Post, $"/products/{productId}/packages", () => Json(new + { + resourceType = "GamePackage", + id = packageId, + state = "PendingUpload", + fileName, + uploadInfo, + })); + return this; + } + + // ---- GetPackage processing poll: GET /products/{id}/packages/{packageId} ---- + + public IngestionMockHandler StubGetPackageProcessing(string productId, string packageId, params string[] stateProgression) + { + var states = stateProgression.Length > 0 ? stateProgression : ["Processed"]; + var responders = states.Select(state => (Func)(() => Json(new + { + resourceType = "GamePackage", + id = packageId, + state, + }))).ToArray(); + + OnSequence(HttpMethod.Get, $"/products/{productId}/packages/{packageId}", responders); + return this; + } + + // ---- ProcessPackage: PUT /products/{id}/packages/{packageId} ---- + + public IngestionMockHandler StubProcessPackage(string productId, string packageId, string state = "Uploaded") + { + On(HttpMethod.Put, $"/products/{productId}/packages/{packageId}", () => Json(new + { + resourceType = "GamePackage", + id = packageId, + state, + })); + return this; + } + + // ---- GetPackageConfig ---- + + public IngestionMockHandler StubPackageConfiguration( + string productId, + string instanceId, + string configId, + string marketGroupId = "default", + string marketGroupName = "default") + { + On(HttpMethod.Get, $"/products/{productId}/packageConfigurations/getByInstanceID*", () => Json(new + { + value = new[] { new { resourceType = "PackageConfiguration", id = configId } }, + })); + + Func single = () => Json(new + { + resourceType = "PackageConfiguration", + id = configId, + marketGroupPackages = new[] + { + new { marketGroupId, name = marketGroupName, packageIds = Array.Empty() }, + }, + }); + On(HttpMethod.Get, $"/products/{productId}/packageConfigurations/{configId}", single); + On(HttpMethod.Put, $"/products/{productId}/packageConfigurations/{configId}", single); + return this; + } + + // ---- CreateSubmission / GetSubmission ---- + + public IngestionMockHandler StubCreateSubmission(string productId, string submissionId) + { + On(HttpMethod.Post, $"/products/{productId}/submissions", () => Json(SubmissionBody(submissionId, "InProgress", "Submitted"))); + return this; + } + + public IngestionMockHandler StubGetSubmission( + string productId, + string submissionId, + params (string State, string Substate)[] progression) + { + var steps = progression.Length > 0 ? progression : [("Published", "InStore")]; + var responders = steps.Select(step => (Func)(() => + Json(SubmissionBody(submissionId, step.State, step.Substate)))).ToArray(); + + OnSequence(HttpMethod.Get, $"/products/{productId}/submissions/{submissionId}", responders); + return this; + } + + // ---- Generic scenario primitives ---- + + public IngestionMockHandler StubError(string method, string pathPattern, HttpStatusCode statusCode) + { + On(HttpMethod.Parse(method), pathPattern, () => Status(statusCode)); + return this; + } + + public IngestionMockHandler StubRetryThenSuccess( + string method, + string path, + object successBody, + int failures = 2, + HttpStatusCode failureStatus = HttpStatusCode.InternalServerError) + { + var responders = new List>(); + for (var i = 0; i < failures; i++) + { + responders.Add(() => Status(failureStatus)); + } + responders.Add(() => Json(successBody)); + + OnSequence(HttpMethod.Parse(method), path, responders); + return this; + } + + // ---- helpers ---- + + private static object SubmissionBody(string submissionId, string state, string substate) => new + { + resourceType = "Submission", + id = submissionId, + state, + substate, + // PendingUpdateInfo.Status is dereferenced by the submission-state mapper, so it must be present. + pendingUpdateInfo = new { status = "Completed" }, + }; + + private static HttpStatusCode StatusFor(ResponseScenario scenario) => scenario switch + { + ResponseScenario.ServerError => HttpStatusCode.InternalServerError, + ResponseScenario.Unauthorized => HttpStatusCode.Unauthorized, + ResponseScenario.NotFound => HttpStatusCode.NotFound, + _ => HttpStatusCode.InternalServerError, + }; +} diff --git a/src/PackageUploader.IntegrationTest/Infrastructure/Mocks/ResponseScenario.cs b/src/PackageUploader.IntegrationTest/Infrastructure/Mocks/ResponseScenario.cs new file mode 100644 index 00000000..4aaf2f24 --- /dev/null +++ b/src/PackageUploader.IntegrationTest/Infrastructure/Mocks/ResponseScenario.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace PackageUploader.IntegrationTest.Infrastructure.Mocks; + +/// How a stubbed endpoint should behave for a single, non-stateful response. +internal enum ResponseScenario +{ + /// Return a normal 2xx response with a valid body. + Success, + + /// Return a 500 Internal Server Error. + ServerError, + + /// Return a 401 Unauthorized. + Unauthorized, + + /// Return a 404 Not Found. + NotFound, +} diff --git a/src/PackageUploader.IntegrationTest/Infrastructure/Mocks/StubHttpMessageHandler.cs b/src/PackageUploader.IntegrationTest/Infrastructure/Mocks/StubHttpMessageHandler.cs new file mode 100644 index 00000000..a270d67d --- /dev/null +++ b/src/PackageUploader.IntegrationTest/Infrastructure/Mocks/StubHttpMessageHandler.cs @@ -0,0 +1,153 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace PackageUploader.IntegrationTest.Infrastructure.Mocks; + +/// +/// In-memory that returns scripted responses and records requests. +/// This is the first-party (BCL-only) equivalent of a mock HTTP server: it is plugged in as the +/// primary handler of the client's named , so the real pipeline (auth +/// handler, Polly policies, serialization) runs against deterministic in-memory responses — no +/// network, no external dependency. +/// +/// Rules are matched by HTTP method and a path pattern where * matches any run of non-slash +/// characters. Rules are evaluated in registration order; the first match wins. Sequential rules +/// return their responses in order across successive calls and then stay on the final one. +/// Unmatched requests return a non-transient 400 so a missing stub fails fast rather than being +/// retried by the client's Polly policy. +/// +/// +internal abstract class StubHttpMessageHandler : HttpMessageHandler +{ + private readonly List _rules = []; + private readonly List _received = []; + private readonly Lock _lock = new(); + + /// Every request observed by the handler, in order, for assertions. + public IReadOnlyList ReceivedRequests + { + get + { + lock (_lock) + { + return _received.ToArray(); + } + } + } + + /// Registers a single response for requests matching the method and path pattern. + protected void On(HttpMethod method, string pathPattern, Func respond) + { + var regex = ToRegex(pathPattern); + lock (_lock) + { + _rules.Add(new Rule(method, regex, _ => respond())); + } + } + + /// + /// Registers a sequence of responses for matching requests: each call returns the next response, + /// and the final one is repeated for all subsequent calls (used for polling and retry). + /// + protected void OnSequence(HttpMethod method, string pathPattern, IReadOnlyList> responders) + { + var regex = ToRegex(pathPattern); + var index = 0; + lock (_lock) + { + _rules.Add(new Rule(method, regex, _ => + { + var responder = responders[Math.Min(index, responders.Count - 1)]; + if (index < responders.Count - 1) + { + index++; + } + return responder(); + })); + } + } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + string? body = request.Content is null + ? null + : await request.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + + Rule? rule; + lock (_lock) + { + _received.Add(new RecordedRequest(request.Method, request.RequestUri!, CloneHeaders(request.Headers), body)); + rule = _rules.FirstOrDefault(r => r.Matches(request)); + } + + if (rule is null) + { + // Non-transient 400 so a missing stub fails fast instead of being retried by Polly. + return new HttpResponseMessage(HttpStatusCode.BadRequest) + { + RequestMessage = request, + Content = new StringContent( + $"No stub registered for {request.Method} {request.RequestUri?.AbsolutePath}", + Encoding.UTF8, "text/plain"), + }; + } + + HttpResponseMessage response; + lock (_lock) + { + response = rule.Respond(request); + } + response.RequestMessage ??= request; + return response; + } + + /// Builds a JSON response with the given status code. + protected static HttpResponseMessage Json(object body, HttpStatusCode status = HttpStatusCode.OK) => + new(status) + { + Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"), + }; + + /// Builds an empty response with the given status code. + protected static HttpResponseMessage Status(HttpStatusCode status) => new(status); + + private static Regex ToRegex(string pathPattern) + { + var escaped = Regex.Escape(pathPattern).Replace("\\*", "[^/]*"); + return new Regex("^" + escaped + "$", RegexOptions.IgnoreCase | RegexOptions.Compiled); + } + + private static IReadOnlyDictionary CloneHeaders(HttpRequestHeaders headers) + { + var clone = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var header in headers) + { + clone[header.Key] = string.Join(", ", header.Value); + } + return clone; + } + + private sealed class Rule(HttpMethod method, Regex pathRegex, Func respond) + { + public bool Matches(HttpRequestMessage request) => + request.Method == method && + request.RequestUri is not null && + pathRegex.IsMatch(request.RequestUri.AbsolutePath); + + public HttpResponseMessage Respond(HttpRequestMessage request) => respond(request); + } +} + +/// Snapshot of a request observed by a . +internal sealed record RecordedRequest( + HttpMethod Method, + Uri Uri, + IReadOnlyDictionary Headers, + string? Body); diff --git a/src/PackageUploader.IntegrationTest/Infrastructure/Mocks/XfusMockHandler.cs b/src/PackageUploader.IntegrationTest/Infrastructure/Mocks/XfusMockHandler.cs new file mode 100644 index 00000000..ee4c8137 --- /dev/null +++ b/src/PackageUploader.IntegrationTest/Infrastructure/Mocks/XfusMockHandler.cs @@ -0,0 +1,109 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using System.Net.Http; + +namespace PackageUploader.IntegrationTest.Infrastructure.Mocks; + +/// +/// First-party (BCL-only) in-memory fake of the XFUS upload service, plugged in as the primary +/// handler of the XFUS . Serves the three-step chunked upload (initialize -> +/// block payload PUT -> continue) rooted at /api/v2/assets/ for the no-delta path, with +/// configurable success / error / retry scenarios. +/// +/// +/// Responses omit directUploadParameters.sasUri so the client uploads blocks via the proxy +/// PUT path. The status field is emitted as a number (ReceivingBlocks=0, Busy=1, Completed=2) +/// because the client's serializer has no string-enum converter. +/// +internal sealed class XfusMockHandler : StubHttpMessageHandler +{ + private const string AssetsRoot = "/api/v2/assets"; + + public XfusMockHandler StubNoDeltaUploadSuccess(params long[] blockSizes) + { + var sizes = blockSizes.Length > 0 ? blockSizes : [64L * 1024]; + StubInitialize(UploadProgress(sizes, XfusStatus.ReceivingBlocks)); + StubBlockUpload(); + StubContinue(UploadProgress([], XfusStatus.Completed)); + return this; + } + + public XfusMockHandler StubInitialize(object uploadProgressBody) + { + On(HttpMethod.Post, $"{AssetsRoot}/*/initialize", () => Json(uploadProgressBody)); + return this; + } + + public XfusMockHandler StubBlockUpload(HttpStatusCode statusCode = HttpStatusCode.OK) + { + On(HttpMethod.Put, $"{AssetsRoot}/*/blocks/*/source/payload", () => Status(statusCode)); + return this; + } + + public XfusMockHandler StubContinue(object uploadProgressBody) + { + On(HttpMethod.Post, $"{AssetsRoot}/*/continue", () => Json(uploadProgressBody)); + return this; + } + + public XfusMockHandler StubContinueProgression(params object[] uploadProgressBodies) + { + var bodies = uploadProgressBodies.Length > 0 ? uploadProgressBodies : [UploadProgress([], XfusStatus.Completed)]; + var responders = bodies.Select(b => (Func)(() => Json(b))).ToArray(); + OnSequence(HttpMethod.Post, $"{AssetsRoot}/*/continue", responders); + return this; + } + + public XfusMockHandler StubError(string method, string pathPattern, HttpStatusCode statusCode) + { + On(HttpMethod.Parse(method), pathPattern, () => Status(statusCode)); + return this; + } + + public XfusMockHandler StubBlockUploadRetryThenSuccess(int failures = 1, HttpStatusCode failureStatus = HttpStatusCode.ServiceUnavailable) + { + var responders = new List>(); + for (var i = 0; i < failures; i++) + { + responders.Add(() => Status(failureStatus)); + } + responders.Add(() => Status(HttpStatusCode.OK)); + + OnSequence(HttpMethod.Put, $"{AssetsRoot}/*/blocks/*/source/payload", responders); + return this; + } + + // ---- helpers ---- + + private enum XfusStatus + { + ReceivingBlocks = 0, + Busy = 1, + Completed = 2, + } + + private static object UploadProgress(long[] blockSizes, XfusStatus status) + { + long offset = 0; + var blocks = new List(); + for (long i = 0; i < blockSizes.Length; i++) + { + blocks.Add(new + { + id = i, + blockIdBase64 = Convert.ToBase64String(BitConverter.GetBytes(i)), + offset, + size = blockSizes[i], + }); + offset += blockSizes[i]; + } + + return new + { + pendingBlocks = blocks, + status = (int)status, + }; + } +} diff --git a/src/PackageUploader.IntegrationTest/Infrastructure/PackageUploaderTestHost.cs b/src/PackageUploader.IntegrationTest/Infrastructure/PackageUploaderTestHost.cs deleted file mode 100644 index 9a3bc6f3..00000000 --- a/src/PackageUploader.IntegrationTest/Infrastructure/PackageUploaderTestHost.cs +++ /dev/null @@ -1,64 +0,0 @@ -// 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; - -/// Composes the real with the Ingestion network handler and access-token provider replaced by test doubles. -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? configureIngestion = null, - string ingestionBaseAddress = "https://ingestion.test.local/") - { - IngestionHandler = new MockHttpMessageHandler(); - configureIngestion?.Invoke(IngestionHandler); - - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - ["IngestionConfig:BaseAddress"] = ingestionBaseAddress, - }) - .Build(); - - var services = new ServiceCollection(); - services.AddSingleton(configuration); - services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance)); - - services.AddPackageUploaderService(IngestionExtensions.AuthenticationMethod.Default); - - services.RemoveAll(); - services.AddScoped(); - - 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(); - } - - public void Dispose() - { - _scope.Dispose(); - _provider.Dispose(); - } -} diff --git a/src/PackageUploader.IntegrationTest/IngestionApiTests.cs b/src/PackageUploader.IntegrationTest/IngestionApiTests.cs new file mode 100644 index 00000000..bf296e44 --- /dev/null +++ b/src/PackageUploader.IntegrationTest/IngestionApiTests.cs @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.VisualStudio.TestTools.UnitTesting; +using PackageUploader.ClientApi.Client.Ingestion.Exceptions; +using PackageUploader.IntegrationTest.Infrastructure; +using PackageUploader.IntegrationTest.Infrastructure.Mocks; +using System.Net; +using System.Threading; + +namespace PackageUploader.IntegrationTest; + +/// +/// End-to-end integration tests for Ingestion API flows, exercising the real service against the +/// WireMock Ingestion fake: success, error mapping, transient-error retry, and paged collections. +/// +[TestClass] +public sealed class IngestionApiTests : IntegrationTestBase +{ + [TestMethod] + public async Task GetProductByProductId_Success_ReturnsMappedProduct() + { + using var host = CreateMockServerHost(); + host.Ingestion.StubGetProduct("9P000TEST"); + + var product = await host.Service.GetProductByProductIdAsync("9P000TEST", CancellationToken.None); + + Assert.IsNotNull(product); + Assert.AreEqual("9P000TEST", product.ProductId); + Assert.AreEqual("Test Product 9P000TEST", product.ProductName); + } + + [TestMethod] + public async Task GetProductByProductId_NotFound_ThrowsProductNotFound() + { + using var host = CreateMockServerHost(); + host.Ingestion.StubGetProduct("MISSING", ResponseScenario.NotFound); + + await Assert.ThrowsExactlyAsync( + () => host.Service.GetProductByProductIdAsync("MISSING", CancellationToken.None)); + } + + [TestMethod] + public async Task GetProductByProductId_RetriesTransientError_ThenSucceeds() + { + using var host = CreateMockServerHost(); + host.Ingestion.StubRetryThenSuccess( + "GET", + "/products/RETRYME", + new { resourceType = "AzureGameProduct", id = "RETRYME", name = "Recovered" }, + failures: 2, + failureStatus: HttpStatusCode.InternalServerError); + + var product = await host.Service.GetProductByProductIdAsync("RETRYME", CancellationToken.None); + + Assert.IsNotNull(product); + Assert.AreEqual("RETRYME", product.ProductId); + } + + [TestMethod] + public async Task GetPackageBranches_ReturnsConfiguredBranches() + { + using var host = CreateMockServerHost(); + host.Ingestion.StubGetProduct("PRODX"); + host.Ingestion.StubGetPackageBranches("PRODX", ("Main", "draft-1"), ("Beta", "draft-2")); + + var product = await host.Service.GetProductByProductIdAsync("PRODX", CancellationToken.None); + var branches = await host.Service.GetPackageBranchesAsync(product, CancellationToken.None); + + Assert.AreEqual(2, branches.Count); + CollectionAssert.AreEquivalent( + new[] { "Main", "Beta" }, + branches.Select(b => b.Name).ToArray()); + } +} diff --git a/src/PackageUploader.IntegrationTest/PublishFlowTests.cs b/src/PackageUploader.IntegrationTest/PublishFlowTests.cs new file mode 100644 index 00000000..b4773989 --- /dev/null +++ b/src/PackageUploader.IntegrationTest/PublishFlowTests.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.VisualStudio.TestTools.UnitTesting; +using PackageUploader.ClientApi.Client.Ingestion.Models; +using PackageUploader.IntegrationTest.Infrastructure; +using System.Threading; + +namespace PackageUploader.IntegrationTest; + +/// +/// End-to-end publish flow that exercises submission creation and polling against the Ingestion +/// fake: create a sandbox submission, then poll it until it reaches the Published state. +/// +[TestClass] +public sealed class PublishFlowTests : IntegrationTestBase +{ + [TestMethod] + public async Task PublishToSandbox_PollsSubmission_UntilPublished() + { + using var host = CreateMockServerHost(); + + const string productId = "PRODPUBLISH"; + const string submissionId = "sub-1"; + + host.Ingestion.StubGetProduct(productId); + host.Ingestion.StubGetPackageBranches(productId, ("Main", "draft-1")); + host.Ingestion.StubCreateSubmission(productId, submissionId); + host.Ingestion.StubGetSubmission(productId, submissionId, ("Published", "InStore")); + + var product = await host.Service.GetProductByProductIdAsync(productId, CancellationToken.None); + var branch = await host.Service.GetPackageBranchByFriendlyNameAsync(product, "Main", CancellationToken.None); + + var submission = await host.Service.PublishPackagesToSandboxAsync( + product, branch, "Sandbox.1", minutesToWaitForPublishing: 1, CancellationToken.None); + + Assert.IsNotNull(submission); + Assert.AreEqual(GameSubmissionState.Published, submission.GameSubmissionState); + } +} diff --git a/src/PackageUploader.IntegrationTest/SmokeTest.cs b/src/PackageUploader.IntegrationTest/SmokeTest.cs index 5a83a4fb..7b0d3e4a 100644 --- a/src/PackageUploader.IntegrationTest/SmokeTest.cs +++ b/src/PackageUploader.IntegrationTest/SmokeTest.cs @@ -3,31 +3,34 @@ using Microsoft.VisualStudio.TestTools.UnitTesting; using PackageUploader.IntegrationTest.Infrastructure; -using System.Net.Http; namespace PackageUploader.IntegrationTest; /// -/// 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. +/// Smoke test that validates the integration project is discovered, builds, and that the mock-server +/// host routes a public service call through the real pipeline to the in-memory Ingestion fake with +/// the fake auth token attached. /// [TestClass] public sealed class SmokeTest : IntegrationTestBase { [TestMethod] - public async Task TestHost_RoutesProductLookup_ThroughMockHandlerWithFakeAuth() + public async Task TestHost_RoutesProductLookup_ThroughFakeWithFakeAuth() { - using var host = CreateHost(mock => - mock.WhenJson(HttpMethod.Get, "/products/", "{\"id\":\"smoke-test-product\"}")); + using var host = CreateMockServerHost(); + host.Ingestion.StubGetProduct("smoke-test-product"); var product = await host.Service.GetProductByProductIdAsync("smoke-test-product", TestContext.CancellationToken); Assert.IsNotNull(product); - Assert.AreEqual(1, host.IngestionHandler.ReceivedRequests.Count); + Assert.AreEqual("smoke-test-product", product.ProductId); - var request = host.IngestionHandler.ReceivedRequests[0]; - Assert.IsTrue(request.Headers.ContainsKey("Authorization")); - StringAssert.Contains(request.Headers["Authorization"], FakeAccessTokenProvider.FakeToken); + var requests = host.Ingestion.ReceivedRequests; + Assert.AreEqual(1, requests.Count); + + var headers = requests[0].Headers; + Assert.IsTrue(headers.ContainsKey("Authorization")); + StringAssert.Contains(headers["Authorization"], FakeAccessTokenProvider.FakeToken); } public TestContext TestContext { get; set; } = null!; diff --git a/src/PackageUploader.IntegrationTest/UploadFlowTests.cs b/src/PackageUploader.IntegrationTest/UploadFlowTests.cs new file mode 100644 index 00000000..073d4be1 --- /dev/null +++ b/src/PackageUploader.IntegrationTest/UploadFlowTests.cs @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.VisualStudio.TestTools.UnitTesting; +using PackageUploader.ClientApi.Client.Ingestion.Models; +using PackageUploader.IntegrationTest.Fixtures; +using PackageUploader.IntegrationTest.Infrastructure; +using System.Linq; +using System.Net.Http; +using System.Threading; + +namespace PackageUploader.IntegrationTest; + +/// +/// End-to-end upload flow that ties both fakes together: the real service creates a package against +/// the Ingestion fake, uploads the file to the XFUS fake (no-delta), then processes and polls the +/// package to completion. +/// +[TestClass] +public sealed class UploadFlowTests : IntegrationTestBase +{ + [TestMethod] + public async Task UploadGamePackage_NoDelta_CompletesThroughIngestionAndXfus() + { + using var host = CreateMockServerHost(); + using var packageFile = SyntheticPackageFile.Create(sizeInBytes: 4096, extension: ".xvc"); + + const string productId = "PRODUPLOAD"; + const string packageId = "pkg-1"; + + host.Ingestion.StubGetProduct(productId); + host.Ingestion.StubGetPackageBranches(productId, ("Main", "draft-1")); + host.Ingestion.StubPackageConfiguration(productId, "draft-1", "config-1", marketGroupId: "NA"); + host.Ingestion.StubCreatePackage(productId, packageId, xfusUploadDomain: host.XfusUploadDomain); + host.Ingestion.StubProcessPackage(productId, packageId, "Processed"); + host.Ingestion.StubGetPackageProcessing(productId, packageId, "Processed"); + host.Xfus.StubNoDeltaUploadSuccess(1024); + + var product = await host.Service.GetProductByProductIdAsync(productId, CancellationToken.None); + var branch = await host.Service.GetPackageBranchByFriendlyNameAsync(product, "Main", CancellationToken.None); + var config = await host.Service.GetPackageConfigurationAsync(product, branch, CancellationToken.None); + var marketGroupPackage = config.MarketGroupPackages[0]; + + var result = await host.Service.UploadGamePackageAsync( + product, + branch, + marketGroupPackage, + packageFile.Path, + gameAssets: null, + minutesToWaitForProcessing: 1, + deltaUpload: false, + isXvc: false, + CancellationToken.None); + + Assert.IsNotNull(result); + Assert.AreEqual(GamePackageState.Processed, result.State); + + // The file was actually uploaded to the XFUS fake: a block payload PUT must have occurred. + Assert.IsTrue( + host.Xfus.ReceivedRequests.Any(r => + r.Method == HttpMethod.Put && + r.Uri.AbsolutePath.Contains("/source/payload")), + "XFUS fake should have received a block payload PUT"); + } +}