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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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).
/// </summary>
public abstract class FakeApiControllerBase : ControllerBase
{
private protected async Task<IActionResult> 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),
};
}
}
16 changes: 16 additions & 0 deletions src/PackageUploader.IntegrationTest/FakeApi/IngestionController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using Microsoft.AspNetCore.Mvc;

namespace PackageUploader.IntegrationTest.FakeApi;

/// <summary>
/// Fake Partner Center Ingestion API. Presents the <c>products/...</c> route space the client calls
/// and delegates each request to the configured <see cref="IngestionScenarioStore"/>.
/// </summary>
public sealed class IngestionController(IngestionScenarioStore store) : FakeApiControllerBase
{
[AcceptVerbs("GET", "POST", "PUT", Route = "products/{**rest}")]
public Task<IActionResult> Handle() => RespondAsync(store);
}
201 changes: 201 additions & 0 deletions src/PackageUploader.IntegrationTest/FakeApi/IngestionScenarioStore.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Configurable scripted responses for the fake Ingestion API, consumed by <see cref="IngestionController"/>.
/// Exposes the same fluent stub surface as the other Task 2 prototypes.
/// </summary>
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<object>() }));
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<FakeResponse>)(() => 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<string>() },
};

// 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<FakeResponse> 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<FakeResponse>)(() =>
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<Func<FakeResponse>>();
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,
};
}
105 changes: 105 additions & 0 deletions src/PackageUploader.IntegrationTest/FakeApi/ScenarioStore.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>A scripted response: an HTTP status code and an optional JSON body object.</summary>
public sealed record FakeResponse(int StatusCode, object? Body = null);

/// <summary>A request observed by the fake API, for test assertions.</summary>
public sealed record RecordedRequest(
HttpMethod Method,
string Path,
IReadOnlyDictionary<string, string> Headers,
string? Body);

/// <summary>
/// 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
/// <see cref="Resolve"/> for each incoming request and <see cref="Record"/> to log it. Rules match by
/// HTTP method and a path pattern where <c>*</c> 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.
/// </summary>
public abstract class ScenarioStore
{
private readonly List<Rule> _rules = [];
private readonly List<RecordedRequest> _received = [];
private readonly Lock _lock = new();

/// <summary>Every request observed by the fake service, in order, for assertions.</summary>
public IReadOnlyList<RecordedRequest> ReceivedRequests
{
get
{
lock (_lock)
{
return _received.ToArray();
}
}
}

protected void On(HttpMethod method, string pathPattern, Func<FakeResponse> respond)
{
var regex = ToRegex(pathPattern);
lock (_lock)
{
_rules.Add(new Rule(method, regex, () => respond()));
}
}

protected void OnSequence(HttpMethod method, string pathPattern, IReadOnlyList<Func<FakeResponse>> 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();
}));
}
}

/// <summary>Returns the scripted response for a request, or 400 if no stub matches.</summary>
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();
}
}

/// <summary>Records an observed request.</summary>
public void Record(string method, string path, IReadOnlyDictionary<string, string> 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<FakeResponse> respond)
{
public bool Matches(HttpMethod method2, string absolutePath) =>
method2 == method && pathRegex.IsMatch(absolutePath);

public FakeResponse Respond() => respond();
}
}
Loading
Loading