diff --git a/src/PackageUploader.IntegrationTest/FakeApi/FakeApiControllerBase.cs b/src/PackageUploader.IntegrationTest/FakeApi/FakeApiControllerBase.cs new file mode 100644 index 00000000..3677bd4b --- /dev/null +++ b/src/PackageUploader.IntegrationTest/FakeApi/FakeApiControllerBase.cs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using Microsoft.AspNetCore.Mvc; +using PackageUploader.IntegrationTest.FakeApi; + +namespace PackageUploader.IntegrationTest.FakeApi; + +/// +/// Base for the fake-API controllers: records the incoming request into the given store and returns +/// the scripted response (status code, with an optional JSON body). +/// +public abstract class FakeApiControllerBase : ControllerBase +{ + private protected async Task RespondAsync(ScenarioStore store) + { + string? body = null; + if (Request.ContentLength is > 0) + { + // Only decode textual bodies; binary payloads (e.g. XFUS octet-stream block uploads) + // would be corrupted by a UTF-8 text read, so record their size instead. + var contentType = Request.ContentType ?? string.Empty; + if (contentType.Contains("json", StringComparison.OrdinalIgnoreCase) || + contentType.StartsWith("text/", StringComparison.OrdinalIgnoreCase)) + { + using var reader = new StreamReader(Request.Body, leaveOpen: true); + body = await reader.ReadToEndAsync(); + } + else + { + body = $"<{Request.ContentLength} binary bytes>"; + } + } + + var headers = Request.Headers.ToDictionary( + h => h.Key, + h => string.Join(", ", h.Value.ToArray()), + StringComparer.OrdinalIgnoreCase); + + var path = Request.Path.Value ?? string.Empty; + store.Record(Request.Method, path, headers, body); + + var response = store.Resolve(Request.Method, path); + if (response.Body is null) + { + return StatusCode(response.StatusCode); + } + + return new ContentResult + { + StatusCode = response.StatusCode, + ContentType = "application/json", + Content = JsonSerializer.Serialize(response.Body), + }; + } +} diff --git a/src/PackageUploader.IntegrationTest/FakeApi/IngestionController.cs b/src/PackageUploader.IntegrationTest/FakeApi/IngestionController.cs new file mode 100644 index 00000000..aec9db89 --- /dev/null +++ b/src/PackageUploader.IntegrationTest/FakeApi/IngestionController.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.AspNetCore.Mvc; + +namespace PackageUploader.IntegrationTest.FakeApi; + +/// +/// Fake Partner Center Ingestion API. Presents the products/... route space the client calls +/// and delegates each request to the configured . +/// +public sealed class IngestionController(IngestionScenarioStore store) : FakeApiControllerBase +{ + [AcceptVerbs("GET", "POST", "PUT", Route = "products/{**rest}")] + public Task Handle() => RespondAsync(store); +} diff --git a/src/PackageUploader.IntegrationTest/FakeApi/IngestionScenarioStore.cs b/src/PackageUploader.IntegrationTest/FakeApi/IngestionScenarioStore.cs new file mode 100644 index 00000000..7125cc03 --- /dev/null +++ b/src/PackageUploader.IntegrationTest/FakeApi/IngestionScenarioStore.cs @@ -0,0 +1,201 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using System.Net.Http; +using PackageUploader.IntegrationTest.Infrastructure.Mocks; + +namespace PackageUploader.IntegrationTest.FakeApi; + +/// +/// Configurable scripted responses for the fake Ingestion API, consumed by . +/// Exposes the same fluent stub surface as the other Task 2 prototypes. +/// +public sealed class IngestionScenarioStore : ScenarioStore +{ + public IngestionScenarioStore StubGetProduct(string productId, ResponseScenario scenario = ResponseScenario.Success) + { + if (scenario != ResponseScenario.Success) + { + On(HttpMethod.Get, $"/products/{productId}", () => new FakeResponse((int)StatusFor(scenario))); + return this; + } + + On(HttpMethod.Get, $"/products/{productId}", () => new FakeResponse(200, new + { + resourceType = "AzureGameProduct", + name = $"Test Product {productId}", + id = productId, + externalIds = new[] { new { type = "StoreId", value = "9TESTBIGID000" } }, + isModularPublishing = true, + })); + return this; + } + + public IngestionScenarioStore 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*", () => new FakeResponse(200, new { value = values })); + On(HttpMethod.Get, $"/products/{productId}/flights", () => new FakeResponse(200, new { value = Array.Empty() })); + return this; + } + + public IngestionScenarioStore StubCreatePackage( + string productId, + string packageId, + string fileName = "test.xvc", + string? xfusUploadDomain = null, + string? xfusId = null, + string xfusTenant = "DCE", + string xfusToken = "fake-xfus-token") + { + object? uploadInfo = xfusUploadDomain is null + ? null + : new + { + fileName, + xfusId = xfusId ?? Guid.NewGuid().ToString(), + token = xfusToken, + uploadDomain = xfusUploadDomain, + xfusTenant, + }; + + On(HttpMethod.Post, $"/products/{productId}/packages", () => new FakeResponse(200, new + { + resourceType = "GamePackage", + id = packageId, + state = "PendingUpload", + fileName, + uploadInfo, + })); + return this; + } + + public IngestionScenarioStore StubGetPackageProcessing(string productId, string packageId, params string[] stateProgression) + { + var states = stateProgression.Length > 0 ? stateProgression : ["Processed"]; + var responders = states.Select(state => (Func)(() => new FakeResponse(200, new + { + resourceType = "GamePackage", + id = packageId, + state, + }))).ToArray(); + + OnSequence(HttpMethod.Get, $"/products/{productId}/packages/{packageId}", responders); + return this; + } + + public IngestionScenarioStore StubProcessPackage(string productId, string packageId, string state = "Processed") + { + // Note: WaitForPackageProcessingAsync returns this PUT result, so this 'state' is the package + // state surfaced by UploadGamePackageAsync (the GET poll state only governs loop timing). + On(HttpMethod.Put, $"/products/{productId}/packages/{packageId}", () => new FakeResponse(200, new + { + resourceType = "GamePackage", + id = packageId, + state, + })); + return this; + } + + public IngestionScenarioStore StubPackageConfiguration( + string productId, + string instanceId, + string configId, + string marketGroupId = "default", + string marketGroupName = "default") + { + var marketGroupPackages = new[] + { + new { marketGroupId, name = marketGroupName, packageIds = Array.Empty() }, + }; + + // Include marketGroupPackages so the service treats the config as already-initialized (the + // common path) rather than entering first-time initialization. + On(HttpMethod.Get, $"/products/{productId}/packageConfigurations/getByInstanceID*", () => new FakeResponse(200, new + { + value = new[] { new { resourceType = "PackageConfiguration", id = configId, marketGroupPackages } }, + })); + + Func single = () => new FakeResponse(200, new + { + resourceType = "PackageConfiguration", + id = configId, + marketGroupPackages, + }); + On(HttpMethod.Get, $"/products/{productId}/packageConfigurations/{configId}", single); + On(HttpMethod.Put, $"/products/{productId}/packageConfigurations/{configId}", single); + return this; + } + + public IngestionScenarioStore StubCreateSubmission(string productId, string submissionId) + { + On(HttpMethod.Post, $"/products/{productId}/submissions", () => new FakeResponse(200, SubmissionBody(submissionId, "InProgress", "Submitted"))); + return this; + } + + public IngestionScenarioStore 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)(() => + new FakeResponse(200, SubmissionBody(submissionId, step.State, step.Substate)))).ToArray(); + + OnSequence(HttpMethod.Get, $"/products/{productId}/submissions/{submissionId}", responders); + return this; + } + + public IngestionScenarioStore StubError(string method, string pathPattern, HttpStatusCode statusCode) + { + On(HttpMethod.Parse(method), pathPattern, () => new FakeResponse((int)statusCode)); + return this; + } + + public IngestionScenarioStore 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(() => new FakeResponse((int)failureStatus)); + } + responders.Add(() => new FakeResponse(200, successBody)); + + OnSequence(HttpMethod.Parse(method), path, responders); + return this; + } + + 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/FakeApi/ScenarioStore.cs b/src/PackageUploader.IntegrationTest/FakeApi/ScenarioStore.cs new file mode 100644 index 00000000..be3f91d8 --- /dev/null +++ b/src/PackageUploader.IntegrationTest/FakeApi/ScenarioStore.cs @@ -0,0 +1,105 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net.Http; +using System.Text.RegularExpressions; + +namespace PackageUploader.IntegrationTest.FakeApi; + +/// A scripted response: an HTTP status code and an optional JSON body object. +public sealed record FakeResponse(int StatusCode, object? Body = null); + +/// A request observed by the fake API, for test assertions. +public sealed record RecordedRequest( + HttpMethod Method, + string Path, + IReadOnlyDictionary Headers, + string? Body); + +/// +/// Per-test configurable store of scripted responses for one fake service. Tests register stubs +/// (success / error / retry / polling) before exercising the client; the matching controller calls +/// for each incoming request and to log it. Rules match by +/// HTTP method and a path pattern where * matches any run of non-slash characters; rules are +/// evaluated in registration order, first match wins, and sequential rules advance per call and stay +/// on the final response. +/// +public abstract class ScenarioStore +{ + private readonly List _rules = []; + private readonly List _received = []; + private readonly Lock _lock = new(); + + /// Every request observed by the fake service, in order, for assertions. + public IReadOnlyList ReceivedRequests + { + get + { + lock (_lock) + { + return _received.ToArray(); + } + } + } + + protected void On(HttpMethod method, string pathPattern, Func respond) + { + var regex = ToRegex(pathPattern); + lock (_lock) + { + _rules.Add(new Rule(method, regex, () => respond())); + } + } + + 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(); + })); + } + } + + /// Returns the scripted response for a request, or 400 if no stub matches. + public FakeResponse Resolve(string method, string absolutePath) + { + var parsed = HttpMethod.Parse(method); + lock (_lock) + { + var rule = _rules.FirstOrDefault(r => r.Matches(parsed, absolutePath)); + return rule is null ? new FakeResponse(400) : rule.Respond(); + } + } + + /// Records an observed request. + public void Record(string method, string path, IReadOnlyDictionary headers, string? body) + { + lock (_lock) + { + _received.Add(new RecordedRequest(HttpMethod.Parse(method), path, headers, body)); + } + } + + private static Regex ToRegex(string pathPattern) + { + var escaped = Regex.Escape(pathPattern).Replace("\\*", "[^/]*"); + return new Regex("^" + escaped + "$", RegexOptions.IgnoreCase | RegexOptions.Compiled); + } + + private sealed class Rule(HttpMethod method, Regex pathRegex, Func respond) + { + public bool Matches(HttpMethod method2, string absolutePath) => + method2 == method && pathRegex.IsMatch(absolutePath); + + public FakeResponse Respond() => respond(); + } +} diff --git a/src/PackageUploader.IntegrationTest/FakeApi/XfusController.cs b/src/PackageUploader.IntegrationTest/FakeApi/XfusController.cs new file mode 100644 index 00000000..1c2ae7ce --- /dev/null +++ b/src/PackageUploader.IntegrationTest/FakeApi/XfusController.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.AspNetCore.Mvc; + +namespace PackageUploader.IntegrationTest.FakeApi; + +/// +/// Fake XFUS upload service. Presents the api/v2/assets/... route space the client calls +/// (initialize, block payload PUT, continue) and delegates each request to the configured +/// . +/// +public sealed class XfusController(XfusScenarioStore store) : FakeApiControllerBase +{ + [AcceptVerbs("GET", "POST", "PUT", Route = "api/v2/assets/{**rest}")] + public Task Handle() => RespondAsync(store); +} diff --git a/src/PackageUploader.IntegrationTest/FakeApi/XfusScenarioStore.cs b/src/PackageUploader.IntegrationTest/FakeApi/XfusScenarioStore.cs new file mode 100644 index 00000000..fcf38bf4 --- /dev/null +++ b/src/PackageUploader.IntegrationTest/FakeApi/XfusScenarioStore.cs @@ -0,0 +1,105 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using System.Net.Http; + +namespace PackageUploader.IntegrationTest.FakeApi; + +/// +/// Configurable scripted responses for the fake XFUS upload service, consumed by . +/// Serves the three-step chunked upload (initialize -> block payload PUT -> continue) 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). +/// +public sealed class XfusScenarioStore : ScenarioStore +{ + private const string AssetsRoot = "/api/v2/assets"; + + public XfusScenarioStore 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 XfusScenarioStore StubInitialize(object uploadProgressBody) + { + On(HttpMethod.Post, $"{AssetsRoot}/*/initialize", () => new FakeResponse(200, uploadProgressBody)); + return this; + } + + public XfusScenarioStore StubBlockUpload(HttpStatusCode statusCode = HttpStatusCode.OK) + { + On(HttpMethod.Put, $"{AssetsRoot}/*/blocks/*/source/payload", () => new FakeResponse((int)statusCode)); + return this; + } + + public XfusScenarioStore StubContinue(object uploadProgressBody) + { + On(HttpMethod.Post, $"{AssetsRoot}/*/continue", () => new FakeResponse(200, uploadProgressBody)); + return this; + } + + public XfusScenarioStore StubContinueProgression(params object[] uploadProgressBodies) + { + var bodies = uploadProgressBodies.Length > 0 ? uploadProgressBodies : [UploadProgress([], XfusStatus.Completed)]; + var responders = bodies.Select(b => (Func)(() => new FakeResponse(200, b))).ToArray(); + OnSequence(HttpMethod.Post, $"{AssetsRoot}/*/continue", responders); + return this; + } + + public XfusScenarioStore StubError(string method, string pathPattern, HttpStatusCode statusCode) + { + On(HttpMethod.Parse(method), pathPattern, () => new FakeResponse((int)statusCode)); + return this; + } + + public XfusScenarioStore StubBlockUploadRetryThenSuccess(int failures = 1, HttpStatusCode failureStatus = HttpStatusCode.ServiceUnavailable) + { + var responders = new List>(); + for (var i = 0; i < failures; i++) + { + responders.Add(() => new FakeResponse((int)failureStatus)); + } + responders.Add(() => new FakeResponse((int)HttpStatusCode.OK)); + + OnSequence(HttpMethod.Put, $"{AssetsRoot}/*/blocks/*/source/payload", responders); + return this; + } + + 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/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..a57c4311 --- /dev/null +++ b/src/PackageUploader.IntegrationTest/Infrastructure/MockServerTestHost.cs @@ -0,0 +1,116 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using PackageUploader.ClientApi; +using PackageUploader.ClientApi.Client.Ingestion.TokenProvider; +using PackageUploader.IntegrationTest.FakeApi; + +namespace PackageUploader.IntegrationTest.Infrastructure; + +/// +/// Hosts a fake-API ASP.NET Core app (Ingestion + XFUS controllers) on a random loopback port via +/// Kestrel, then composes the real pointed at it. The client +/// makes real HTTP calls over a loopback socket — its full pipeline (auth handler, Polly policies, +/// the XFUS SocketsHttpHandler, serialization, mappers) runs for real against the fake app. +/// Authentication uses . Uses only the ASP.NET Core shared +/// framework — no third-party package. +/// +internal sealed class MockServerTestHost : IDisposable +{ + private readonly WebApplication _app; + private readonly ServiceProvider _provider; + private readonly IServiceScope _scope; + + /// The fake Ingestion API. Configure stubs before exercising the service. + public IngestionScenarioStore Ingestion { get; } + + /// The fake XFUS upload service. Configure stubs before exercising the service. + public XfusScenarioStore Xfus { get; } + + /// The fully composed, public service under test, wired to the fake app. + public IPackageUploaderService Service { get; } + + /// The fake app's base URL, also used as the XFUS upload domain in package responses. + public string XfusUploadDomain { get; } + + public MockServerTestHost() + { + Ingestion = new IngestionScenarioStore(); + Xfus = new XfusScenarioStore(); + + _app = BuildFakeApp(Ingestion, Xfus); + _app.StartAsync().GetAwaiter().GetResult(); + try + { + XfusUploadDomain = _app.Services.GetRequiredService() + .Features.Get()!.Addresses.First(); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["IngestionConfig:BaseAddress"] = $"{XfusUploadDomain}/", + // 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(); + + _provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true }); + _scope = _provider.CreateScope(); + Service = _scope.ServiceProvider.GetRequiredService(); + } + catch + { + // Avoid leaking the started Kestrel listener if composition fails. + StopApp(); + throw; + } + } + + private static WebApplication BuildFakeApp(IngestionScenarioStore ingestion, XfusScenarioStore xfus) + { + var builder = WebApplication.CreateBuilder(); + builder.Logging.ClearProviders(); + builder.WebHost.UseUrls("http://127.0.0.1:0"); + + builder.Services.AddSingleton(ingestion); + builder.Services.AddSingleton(xfus); + builder.Services.AddControllers().AddApplicationPart(typeof(IngestionController).Assembly); + + var app = builder.Build(); + app.MapControllers(); + return app; + } + + public void Dispose() + { + _scope?.Dispose(); + _provider?.Dispose(); + StopApp(); + } + + private void StopApp() + { + _app.StopAsync().GetAwaiter().GetResult(); + ((IAsyncDisposable)_app).DisposeAsync().AsTask().GetAwaiter().GetResult(); + } +} diff --git a/src/PackageUploader.IntegrationTest/Infrastructure/Mocks/ResponseScenario.cs b/src/PackageUploader.IntegrationTest/Infrastructure/Mocks/ResponseScenario.cs new file mode 100644 index 00000000..b81a5cc3 --- /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. +public 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/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/PackageUploader.IntegrationTest.csproj b/src/PackageUploader.IntegrationTest/PackageUploader.IntegrationTest.csproj index 85e2569e..829ceda3 100644 --- a/src/PackageUploader.IntegrationTest/PackageUploader.IntegrationTest.csproj +++ b/src/PackageUploader.IntegrationTest/PackageUploader.IntegrationTest.csproj @@ -18,6 +18,10 @@ + + + + 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..b82133d5 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 over real HTTP to the fake Ingestion API with the fake auth +/// token attached. /// [TestClass] public sealed class SmokeTest : IntegrationTestBase { [TestMethod] - public async Task TestHost_RoutesProductLookup_ThroughMockHandlerWithFakeAuth() + public async Task TestHost_RoutesProductLookup_ThroughFakeApiWithFakeAuth() { - 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..c2b306e3 --- /dev/null +++ b/src/PackageUploader.IntegrationTest/UploadFlowTests.cs @@ -0,0 +1,73 @@ +// 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.Path.Contains("/source/payload")), + "XFUS fake should have received a block payload PUT"); + + // The package processing poll was actually issued (a GET on the package), so the polling + // stub is load-bearing rather than incidentally satisfied by the process (PUT) result. + Assert.IsTrue( + host.Ingestion.ReceivedRequests.Any(r => + r.Method == HttpMethod.Get && + r.Path.Contains($"/packages/{packageId}")), + "Ingestion fake should have received the package processing poll"); + } +} diff --git a/src/PackageUploader.IntegrationTest/packages.lock.json b/src/PackageUploader.IntegrationTest/packages.lock.json index 2bf55d70..1d960a7b 100644 --- a/src/PackageUploader.IntegrationTest/packages.lock.json +++ b/src/PackageUploader.IntegrationTest/packages.lock.json @@ -64,8 +64,6 @@ "contentHash": "m6hHbx1q9+GCBZ5A9ykzFylPdTwscX2APH7PlnqV+yu+DH3RRtuIDJMRqdU17cMyinv0hCPofpegoyQ6qWPW7g==", "dependencies": { "Microsoft.Bcl.AsyncInterfaces": "10.0.3", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", - "Microsoft.Extensions.Hosting.Abstractions": "10.0.3", "Microsoft.Identity.Client": "4.83.1", "Microsoft.Identity.Client.Extensions.Msal": "4.83.1", "System.ClientModel": "1.10.0", @@ -101,10 +99,7 @@ "Castle.Core": { "type": "Transitive", "resolved": "5.1.1", - "contentHash": "rpYtIczkzGpf+EkZgDr9CClTdemhsrwA/W5hMoPjLkRFnXzH44zDLoovXeKtmxb1ykXK9aJVODSpiJml8CTw2g==", - "dependencies": { - "System.Diagnostics.EventLog": "6.0.0" - } + "contentHash": "rpYtIczkzGpf+EkZgDr9CClTdemhsrwA/W5hMoPjLkRFnXzH44zDLoovXeKtmxb1ykXK9aJVODSpiJml8CTw2g==" }, "Microsoft.ApplicationInsights": { "type": "Transitive", @@ -121,322 +116,20 @@ "resolved": "2.2.3", "contentHash": "bhwzJfzyiJM0nXJyNB7Y9OfsEXyxLdDBHG99soIp5JjnPydwkOaBdRCtRtWgQh3noSLi2cSIZ/wpbHNNE9knxQ==" }, - "Microsoft.Extensions.Configuration": { - "type": "Transitive", - "resolved": "10.0.7", - "contentHash": "wZbGh7J8R1vXN525O6d8dlcDTxhRTnd5MyW4LdfP5S0tSnTwTCseYSrq6g0Mxh7W9xn8P/2xPuf0D/m6k2dy2w==", - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.7", - "Microsoft.Extensions.Primitives": "10.0.7" - } - }, - "Microsoft.Extensions.Configuration.Abstractions": { - "type": "Transitive", - "resolved": "10.0.7", - "contentHash": "t56nEgvECcyLPojZIUFWJknQQDAbgfTf9J+QMYJE1YYvVgz69vN6B/AKL8Grvj3Lcnp8kTpNqwmwFhb3YLJmtQ==", - "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.7" - } - }, - "Microsoft.Extensions.Configuration.Binder": { - "type": "Transitive", - "resolved": "10.0.7", - "contentHash": "8bS1qIaRivny+WX+49pmeJ6iAylbtX8C0DLEcCQWZjdxQvLqaMssXiGD9P/6pYElrHbK5/nAHmjbQ8STqdMYeg==", - "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.7", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.7" - } - }, - "Microsoft.Extensions.Configuration.CommandLine": { - "type": "Transitive", - "resolved": "10.0.7", - "contentHash": "3lNjglxfFxOzI9zG+3HSg/YSGqo//8Fqw6u6iuIamZb4JCorbA3JLaeWOpfKTAPi2UJwaispOXWx14dUqcGz4A==", - "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.7", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.7" - } - }, - "Microsoft.Extensions.Configuration.EnvironmentVariables": { - "type": "Transitive", - "resolved": "10.0.7", - "contentHash": "TWto3imA+mJMLZI+5sbgLiFFoOFNFkizQYNaC5jTuiHKn3diwm1RN7mWDOEZN9kG2bixw7IvgpvtUG5/teSRzA==", - "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.7", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.7" - } - }, - "Microsoft.Extensions.Configuration.FileExtensions": { - "type": "Transitive", - "resolved": "10.0.7", - "contentHash": "qbZLvLsoTdArSloEnSxs21P781YUmwVmHc5NJPQD/ezAreQ7884z+6QfAZVKi86WAZtzx83jK2uC4itxOM44gQ==", - "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.7", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.7", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.7", - "Microsoft.Extensions.FileProviders.Physical": "10.0.7", - "Microsoft.Extensions.Primitives": "10.0.7" - } - }, - "Microsoft.Extensions.Configuration.Json": { - "type": "Transitive", - "resolved": "10.0.7", - "contentHash": "64dimvyyKk0dbUbrLg/YCv4ugJ4sVz2aXLwfvZwR1EC4tJqW9ru/oVRcXwoJRa2lQGXtYtlpk4maWOeIb48tQw==", - "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.7", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.7", - "Microsoft.Extensions.Configuration.FileExtensions": "10.0.7", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.7" - } - }, - "Microsoft.Extensions.Configuration.UserSecrets": { - "type": "Transitive", - "resolved": "10.0.7", - "contentHash": "YqVIICoIdl0016wkeO2WQS+uEbEXbUhMLKdC5rZNl1X3nu59F+nwaAHdHjq/4OK+Cx31DYmNUSFh+MUot8qSDw==", - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.7", - "Microsoft.Extensions.Configuration.Json": "10.0.7", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.7", - "Microsoft.Extensions.FileProviders.Physical": "10.0.7" - } - }, - "Microsoft.Extensions.DependencyInjection": { - "type": "Transitive", - "resolved": "10.0.7", - "contentHash": "91F/o3emPV/+xY/ip3s2LqDNF14kjttlVtq0BXgg6p4MnCzeSZxnUJm+t6WRrtD3JdGo88/oX+z7OwK4y8PZuw==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.7" - } - }, - "Microsoft.Extensions.DependencyInjection.Abstractions": { - "type": "Transitive", - "resolved": "10.0.7", - "contentHash": "Z6mfFEaFcwCfSboxJwOLfu7/31npCY9q70WUamHW/vRQhDvBKOT4Vf9YkZj5J6hLvJpb0oDEYfHunQZj0xxvKw==" - }, "Microsoft.Extensions.DependencyModel": { "type": "Transitive", "resolved": "8.0.2", "contentHash": "mUBDZZRgZrSyFOsJ2qJJ9fXfqd/kXJwf3AiDoqLD9m6TjY5OO/vLNOb9fb4juC0487eq4hcGN/M2Rh/CKS7QYw==" }, - "Microsoft.Extensions.Diagnostics": { - "type": "Transitive", - "resolved": "10.0.7", - "contentHash": "l+smp1qPlU0OUXD0OGfdp7OUFrbdq7ZaP5T7m2WpfZ4RFKD7iG73BAT7tjSMxNmbSXkhAn1jYHOAqzYG1r9sNg==", - "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.7", - "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.7", - "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.7" - } - }, - "Microsoft.Extensions.Diagnostics.Abstractions": { - "type": "Transitive", - "resolved": "10.0.7", - "contentHash": "uJ9JP677y+uy+C0vtaSfi7XXgFAdz8DhU3M9lwwIXDfQKcyQ0yxM9DVYa0NXDtdVTYA2eBUtVFZ8LY0GCdeE/w==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.7", - "Microsoft.Extensions.Options": "10.0.7" - } - }, - "Microsoft.Extensions.FileProviders.Abstractions": { - "type": "Transitive", - "resolved": "10.0.7", - "contentHash": "teioDgVpi8L186wUfrXQV1YuBt6lCSPmFZiMZo53+FZxHFjOV+f4GXo4LXgJ273Mku9//AdXWVjk9J7eJP6inw==", - "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.7" - } - }, - "Microsoft.Extensions.FileProviders.Physical": { - "type": "Transitive", - "resolved": "10.0.7", - "contentHash": "zhgWg/i0ECj5v0jLFBSZHplvc5ygCI91DR4nne+BP4XAKF5ycz0pEKnFiTw8C1jCABJEZsnBZh6pXAvn71kFmw==", - "dependencies": { - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.7", - "Microsoft.Extensions.FileSystemGlobbing": "10.0.7", - "Microsoft.Extensions.Primitives": "10.0.7" - } - }, - "Microsoft.Extensions.FileSystemGlobbing": { - "type": "Transitive", - "resolved": "10.0.7", - "contentHash": "NTUspqB+vH9g4wAD6KPOBx01xqYuKXR/cHXm449zpbq1GqfjdAxBmg7eJXrNsPw7SKwIdT2cJ05GxYVvc+lvsA==" - }, - "Microsoft.Extensions.Hosting": { - "type": "Transitive", - "resolved": "10.0.7", - "contentHash": "M/vBpfWcschvS2EUeq7cHfscsxabiGTptXwV7GeSueovGiSoNjyo1j5PMcWuOAAQrRW3nRqxZk8NeumrmpzUBg==", - "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.7", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.7", - "Microsoft.Extensions.Configuration.Binder": "10.0.7", - "Microsoft.Extensions.Configuration.CommandLine": "10.0.7", - "Microsoft.Extensions.Configuration.EnvironmentVariables": "10.0.7", - "Microsoft.Extensions.Configuration.FileExtensions": "10.0.7", - "Microsoft.Extensions.Configuration.Json": "10.0.7", - "Microsoft.Extensions.Configuration.UserSecrets": "10.0.7", - "Microsoft.Extensions.DependencyInjection": "10.0.7", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.7", - "Microsoft.Extensions.Diagnostics": "10.0.7", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.7", - "Microsoft.Extensions.FileProviders.Physical": "10.0.7", - "Microsoft.Extensions.Hosting.Abstractions": "10.0.7", - "Microsoft.Extensions.Logging": "10.0.7", - "Microsoft.Extensions.Logging.Abstractions": "10.0.7", - "Microsoft.Extensions.Logging.Configuration": "10.0.7", - "Microsoft.Extensions.Logging.Console": "10.0.7", - "Microsoft.Extensions.Logging.Debug": "10.0.7", - "Microsoft.Extensions.Logging.EventLog": "10.0.7", - "Microsoft.Extensions.Logging.EventSource": "10.0.7", - "Microsoft.Extensions.Options": "10.0.7" - } - }, - "Microsoft.Extensions.Hosting.Abstractions": { - "type": "Transitive", - "resolved": "10.0.7", - "contentHash": "5s8d6qC6EA8UOI4wR/+zlsq7SXttJMRb9d7zvVZ7+bE3CQEfVtC9ITUDCommm87R1zzj6WJBbCnztuIJXnP3DA==", - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.7", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.7", - "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.7", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.7", - "Microsoft.Extensions.Logging.Abstractions": "10.0.7" - } - }, - "Microsoft.Extensions.Http": { - "type": "Transitive", - "resolved": "10.0.7", - "contentHash": "1wbd+RPhRo3hJKNJhdGEO5ls0LGe55Ho4BUjlFtRUrWxDVVBd7g0Ydq9fbNy86pmvx/j7AGcSPo7YNCo1IRI6Q==", - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.7", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.7", - "Microsoft.Extensions.Diagnostics": "10.0.7", - "Microsoft.Extensions.Logging": "10.0.7", - "Microsoft.Extensions.Logging.Abstractions": "10.0.7", - "Microsoft.Extensions.Options": "10.0.7" - } - }, "Microsoft.Extensions.Http.Polly": { "type": "Transitive", "resolved": "10.0.7", "contentHash": "pcUsPoqMHvOp+QJsLA/Hlg/W+IBnAoUXKEBc7FqMcY0sUez15DOKXtbEo81TvHL9xwjWQcF3ZMayNpcvpI7Bqg==", "dependencies": { - "Microsoft.Extensions.Http": "10.0.7", "Polly": "7.2.4", "Polly.Extensions.Http": "3.0.0" } }, - "Microsoft.Extensions.Logging": { - "type": "Transitive", - "resolved": "10.0.7", - "contentHash": "hOeRIQ63GkgiYCB/MIFp+LQs8aXpJXpB55t6Aj37ab7t2/6WeFcPXxYM9hdy/o5tffzwf8mhqzLJP6mjGYCxjw==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection": "10.0.7", - "Microsoft.Extensions.Logging.Abstractions": "10.0.7", - "Microsoft.Extensions.Options": "10.0.7" - } - }, - "Microsoft.Extensions.Logging.Abstractions": { - "type": "Transitive", - "resolved": "10.0.7", - "contentHash": "tIEcQ2gvERrH2KiCjdsVcHGhXt9lIsuDStfOIeZWr7/fP8IXhGiYfx0/80PNI7WPO2IYuFtlZLSlnTS8+/Mchw==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.7" - } - }, - "Microsoft.Extensions.Logging.Configuration": { - "type": "Transitive", - "resolved": "10.0.7", - "contentHash": "7BBnoGF37USiu7j434put9mDp7EjdlNDIZsR4vHfC1FbLZeLqiWjgJbeEtF0p59Ryqt8AtraHawf0ZKbe5jibg==", - "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.7", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.7", - "Microsoft.Extensions.Configuration.Binder": "10.0.7", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.7", - "Microsoft.Extensions.Logging": "10.0.7", - "Microsoft.Extensions.Logging.Abstractions": "10.0.7", - "Microsoft.Extensions.Options": "10.0.7", - "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.7" - } - }, - "Microsoft.Extensions.Logging.Console": { - "type": "Transitive", - "resolved": "10.0.7", - "contentHash": "DA++Es6v6W0HfrOrw+K8WyN6jNnZHp640PDdEvl8yfeVmgflKdn6vSSFvufNUSOuY+M2ZaSUgfY+jUKtNpXcCw==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.7", - "Microsoft.Extensions.Logging": "10.0.7", - "Microsoft.Extensions.Logging.Abstractions": "10.0.7", - "Microsoft.Extensions.Logging.Configuration": "10.0.7", - "Microsoft.Extensions.Options": "10.0.7" - } - }, - "Microsoft.Extensions.Logging.Debug": { - "type": "Transitive", - "resolved": "10.0.7", - "contentHash": "Y6DSt/JZApunYWKqTtqbdsR6iqAvHx3D0tavbNJ1rnC24MUpF+3XO/VKgFi+9PFqMyvQ2GHBBGb8H3cLSw7rDg==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.7", - "Microsoft.Extensions.Logging": "10.0.7", - "Microsoft.Extensions.Logging.Abstractions": "10.0.7" - } - }, - "Microsoft.Extensions.Logging.EventLog": { - "type": "Transitive", - "resolved": "10.0.7", - "contentHash": "1C8eTuxF6BLncNSJ1HCfmaBcjpUSqQDPlBVdYTlet9oldHTPpNh9iatxSJLs8TOqdp/FOpH+nSLdBve7fu9mTQ==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.7", - "Microsoft.Extensions.Logging": "10.0.7", - "Microsoft.Extensions.Logging.Abstractions": "10.0.7", - "Microsoft.Extensions.Options": "10.0.7", - "System.Diagnostics.EventLog": "10.0.7" - } - }, - "Microsoft.Extensions.Logging.EventSource": { - "type": "Transitive", - "resolved": "10.0.7", - "contentHash": "YWfndnDX1jVMGCN8d5T+rO+BO8sDw6BkYlUk0BYui+WP7+HhlWx8QLdA4yUDjrkGVb3AQxIWWEPVKw5Nnfj5GQ==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.7", - "Microsoft.Extensions.Logging": "10.0.7", - "Microsoft.Extensions.Logging.Abstractions": "10.0.7", - "Microsoft.Extensions.Options": "10.0.7", - "Microsoft.Extensions.Primitives": "10.0.7" - } - }, - "Microsoft.Extensions.Options": { - "type": "Transitive", - "resolved": "10.0.7", - "contentHash": "00SHUGTh2jSMvIr6x9Xwd2nE+B5/qFCO/9hDwUDhJsjYRDlADmaBZ7tqehXzBDsfjHSXJzuRHJzPYPPjphBQ7Q==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.7", - "Microsoft.Extensions.Primitives": "10.0.7" - } - }, - "Microsoft.Extensions.Options.ConfigurationExtensions": { - "type": "Transitive", - "resolved": "10.0.7", - "contentHash": "IT7f+EMXZtkjatEcF+o6aOw/7OE4etRrMiDGEWH/iiTu2R3uhC4NEQJCfHiibtX45U3sIQ5Fh6tbb1qaOz3YAg==", - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.7", - "Microsoft.Extensions.Configuration.Binder": "10.0.7", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.7", - "Microsoft.Extensions.Options": "10.0.7", - "Microsoft.Extensions.Primitives": "10.0.7" - } - }, - "Microsoft.Extensions.Options.DataAnnotations": { - "type": "Transitive", - "resolved": "10.0.7", - "contentHash": "KWepqdSD4PxhFvVh3mckkvJ03u3q/VChkr6nT3nf5mm2XBk8ojxt2E4It0RMblb3GE7hJ0zQzFzxGKL0d6TfXA==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.7", - "Microsoft.Extensions.Options": "10.0.7" - } - }, - "Microsoft.Extensions.Primitives": { - "type": "Transitive", - "resolved": "10.0.7", - "contentHash": "D5M0Jr551iTgwkZMN9rm0pSkgNLj5quUWQUmQPMZh7k/bnvZTnXRGfE2KuvXf1EEjt/ofD9yw9IumpgdP9QCnw==" - }, "Microsoft.Identity.Client": { "type": "Transitive", "resolved": "4.83.1", @@ -533,9 +226,6 @@ "resolved": "1.10.0", "contentHash": "lBEWs54F5Y5pZ9hC+8z4S/X76957ex+DPk7WecRHlbIHtrPfbRMMlOgI3iDn4Jpb3bSxvBnKaaHoD59auFjlBA==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", - "Microsoft.Extensions.Hosting.Abstractions": "10.0.3", - "Microsoft.Extensions.Logging.Abstractions": "10.0.3", "System.Memory.Data": "10.0.3" } }, @@ -544,11 +234,6 @@ "resolved": "2.0.7", "contentHash": "ih4yNLLF2Ebz85xJJBaPeddLa4d1AekYId7Y1g8oSsEaBHHd/CtyeBJ+tDvQadqeXz7i591K5ry/td+4aaHnQA==" }, - "System.Diagnostics.EventLog": { - "type": "Transitive", - "resolved": "10.0.7", - "contentHash": "WbmDLeTPYhEzXhvYVioTVn/D1XX6bovyny9n5p8Zxtf03+eY385RB818teZm6n+fA63iZNvng0/Np4tLuhkMhQ==" - }, "System.Interactive.Async": { "type": "Transitive", "resolved": "7.0.1", @@ -580,10 +265,6 @@ "PackageUploader": { "type": "Project", "dependencies": { - "Microsoft.Extensions.Configuration.Binder": "[10.0.7, )", - "Microsoft.Extensions.Configuration.Json": "[10.0.7, )", - "Microsoft.Extensions.Hosting": "[10.0.7, )", - "Microsoft.Extensions.Options.DataAnnotations": "[10.0.7, )", "PackageUploader.ClientApi": "[1.0.0, )", "PackageUploader.FileLogger": "[1.0.0, )", "System.CommandLine": "[2.0.7, )" @@ -595,22 +276,13 @@ "Azure.Core": "[1.54.0, )", "Azure.Identity": "[1.21.0, )", "Azure.Storage.Blobs": "[12.27.0, )", - "Microsoft.Extensions.Configuration.Binder": "[10.0.7, )", - "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.7, )", - "Microsoft.Extensions.Http": "[10.0.7, )", "Microsoft.Extensions.Http.Polly": "[10.0.7, )", - "Microsoft.Extensions.Options.ConfigurationExtensions": "[10.0.7, )", - "Microsoft.Extensions.Options.DataAnnotations": "[10.0.7, )", "Polly.Contrib.WaitAndRetry": "[1.1.1, )", "System.Linq.Async": "[7.0.1, )" } }, "packageuploader.filelogger": { - "type": "Project", - "dependencies": { - "Microsoft.Extensions.Logging": "[10.0.7, )", - "Microsoft.Extensions.Logging.Configuration": "[10.0.7, )" - } + "type": "Project" } } }