tests/test_hosted.cs
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using HumanoidMocapX.Core.Motion;
using HumanoidMocapX;
using HumanoidMocapX.EditorTools;
static class HostedTests
{
const string Key = "test-key-00000000000000000000000000000000";
internal static async Task Run()
{
var settings = new HostingSettings { Enabled = true, ServiceUrl = "https://host.example/prefix" };
var profile = new HostProfile(new HostUser("discord:1", "discord", null, null, null), true, false, true, true, false);
void SignIn() => settings.SignIn(Key, DateTimeOffset.UtcNow.AddHours(1), profile);
var changes = 0; settings.Changed += () => changes++;
MotionTests.Check(new HostedCapture(settings, new CaptureOptions(), Path.GetTempPath()).UnavailableReason!.Contains("Sign in"), "Signed-out state must ask for sign-in");
SignIn();
MotionTests.Check(settings.SignedIn && changes == 1 && new HostedCapture(settings, new CaptureOptions(), Path.GetTempPath()).UnavailableReason is null, "Sign-in must enable processing");
settings.Expire("an-older-token");
MotionTests.Check(settings.SignedIn && changes == 1, "A 401 for an older token must not end the current sign-in");
settings.Update(Key, profile with { CanProcess = false });
MotionTests.Check(new HostedCapture(settings, new CaptureOptions(), Path.GetTempPath()).UnavailableReason!.Contains("whitelisted"), "Profile refresh must apply");
SignIn();
MotionTests.Equal("https://host.example/prefix/", settings.Endpoint().AbsoluteUri);
foreach (var invalid in new[] { "http://host.example", "file:///tmp/file", "https://user:[email protected]", "https://host.example/?token=secret" })
{ settings.ServiceUrl = invalid; MotionTests.Throws<ArgumentException>(() => settings.Endpoint()); }
settings.ServiceUrl = "https://host.example/prefix";
new HostedCaptureOptions(FlowModel.Base, 30, 2, 9).Validate();
MotionTests.Throws<ArgumentException>(() => new HostedCaptureOptions(FlowModel.Latest, 10, double.NaN, null).Validate());
MotionTests.Throws<ArgumentException>(() => new HostedCaptureOptions(FlowModel.Latest, 10, 3, 2).Validate());
var folder = Path.Combine(Path.GetTempPath(), "mocap-hosted-test-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(folder);
var video = Path.Combine(folder, "input.mp4"); var bytes = new byte[] { 1, 2, 3, 4, 5 };
await File.WriteAllBytesAsync(video, bytes);
try
{
var motion = MotionTests.Fixture().ToDocument(); motion.SourceVideo = "/host/private/video.mp4";
motion.OriginalReconstruction = new MotionOrigin { Path = "/host/private/original.hmotion", Sha256 = new string('a', 64) };
var original = motion.ToJson();
var handler = new HostHandler(original, bytes);
using (var capture = new HostedCapture(settings, new CaptureOptions { Model = FlowModel.Base, CameraInterval = 30 }, folder, handler))
{
var output = await capture.CaptureAsync(video, 2, 9, null, default);
var parsed = MotionDocument.Parse(await File.ReadAllBytesAsync(output));
MotionTests.Equal(video, parsed.SourceVideo); MotionTests.Equal(motion.Frames.Count, parsed.Frames.Count);
MotionTests.Check(parsed.OriginalReconstruction is null, "Remote sidecar metadata must not select a local file");
MotionTests.Check(handler.Deleted, "Completed host job must be deleted after download");
MotionTests.Equal("Base", handler.Options.GetProperty("model").GetString());
MotionTests.Equal(30, handler.Options.GetProperty("cameraInterval").GetInt32());
MotionTests.Near(2, handler.Options.GetProperty("start").GetDouble());
MotionTests.Near(9, handler.Options.GetProperty("end").GetDouble());
}
var before = Directory.GetFiles(folder, "*.hmotion").Length;
handler = new HostHandler("{\"schema\":99}", bytes);
using (var capture = new HostedCapture(settings, new CaptureOptions(), folder, handler))
await Throws<FormatException>(() => capture.CaptureAsync(video, 0, null, null, default));
MotionTests.Equal(before, Directory.GetFiles(folder, "*.hmotion").Length);
MotionTests.Equal(0, Directory.GetFiles(folder, "*.partial").Length);
MotionTests.Check(handler.Deleted, "Invalid results must clean up their remote job");
using var cancelled = new CancellationTokenSource();
handler = new HostHandler(original, bytes) { OnUpload = cancelled.Cancel };
using (var capture = new HostedCapture(settings, new CaptureOptions(), folder, handler))
await Throws<OperationCanceledException>(() => capture.CaptureAsync(video, 0, null, null, cancelled.Token));
MotionTests.Check(handler.Deleted, "Cancellation must still send remote DELETE");
handler = new HostHandler(original, bytes) { Available = false };
using (var capture = new HostedCapture(settings, new CaptureOptions(), folder, handler))
await Throws<InvalidOperationException>(() => capture.CaptureAsync(video, 0, null, null, default));
MotionTests.Check(!handler.Created, "An unconfigured host must not receive video");
handler = new HostHandler(original, bytes) { Rejected = true };
using (var capture = new HostedCapture(settings, new CaptureOptions(), folder, handler))
await Throws<HttpRequestException>(() => capture.CaptureAsync(video, 0, null, null, default));
MotionTests.Check(!handler.Created && !settings.SignedIn, "Rejected credentials must sign out without uploading video");
SignIn();
handler = new HostHandler(original, bytes) { Clip = 3 };
using (var capture = new HostedCapture(settings, new CaptureOptions(), folder, handler))
await Throws<InvalidDataException>(() => capture.CaptureAsync(video, 0, 5, null, default));
MotionTests.Check(!handler.Created, "Clips over the account's limit must stop before upload");
var delays = new List<double>(); var messages = new List<string>();
handler = new HostHandler(original, bytes) { TransientPolls = 2, QueuedPolls = 1 };
using (var capture = new HostedCapture(settings, new CaptureOptions(), folder, handler,
(time, token) => { token.ThrowIfCancellationRequested(); delays.Add(time.TotalSeconds); return Task.CompletedTask; }))
await capture.CaptureAsync(video, 0, 5, messages.Add, default);
MotionTests.Check(delays.SequenceEqual(new[] { 2d, 2d, 7d }), "Queue polling ignored Retry-After");
MotionTests.Check(messages.Any(m => m.Contains("Waiting · 2 in line")), "Queue position was not displayed");
MotionTests.Equal("Waiting · 2 of 5 in line (priority) · starts in 2 min · about 1 h 5 min left",
HostedCapture.Describe(new HostedJob(Guid.Empty, "queued", "Waiting", 2, QueueLength: 5, SecondsUntilStart: 100, SecondsRemaining: 3900, Priority: true)));
MotionTests.Equal(4, handler.PollCount);
delays.Clear(); handler = new HostHandler(original, bytes) { TransientPolls = 20 };
using (var capture = new HostedCapture(settings, new CaptureOptions(), folder, handler,
(time, token) => { delays.Add(time.TotalSeconds); return Task.CompletedTask; }))
await Throws<HttpRequestException>(() => capture.CaptureAsync(video, 0, 5, null, default));
MotionTests.Equal(6, delays.Count); MotionTests.Equal(7, handler.PollCount);
MotionTests.Check(handler.Deleted, "Exhausted status retries must clean up the known job");
handler = new HostHandler(original, bytes) { QueuedPolls = 1, Position = 0 };
using (var capture = new HostedCapture(settings, new CaptureOptions(), folder, handler))
await Throws<InvalidDataException>(() => capture.CaptureAsync(video, 0, 5, null, default));
MotionTests.Check(handler.Deleted, "Malformed queue position must clean up the known job");
using var queuedCancellation = new CancellationTokenSource();
handler = new HostHandler(original, bytes) { QueuedPolls = 1 };
using (var capture = new HostedCapture(settings, new CaptureOptions(), folder, handler,
(time, token) => { queuedCancellation.Cancel(); return Task.FromCanceled(token); }))
await Throws<OperationCanceledException>(() => capture.CaptureAsync(video, 0, 5, null, queuedCancellation.Token));
MotionTests.Check(handler.Deleted, "Cancellation during queue delay must send remote DELETE");
var now = DateTimeOffset.UtcNow;
MotionTests.Near(1, HostedCapture.PollDelay(null, now).TotalSeconds);
MotionTests.Near(30, HostedCapture.PollDelay(new RetryConditionHeaderValue(TimeSpan.FromMinutes(5)), now).TotalSeconds);
MotionTests.Near(1, HostedCapture.PollDelay(new RetryConditionHeaderValue(now.AddSeconds(-5)), now).TotalSeconds);
MotionTests.Near(12, HostedCapture.PollDelay(new RetryConditionHeaderValue(now.AddSeconds(12)), now).TotalSeconds);
handler = new HostHandler(original, bytes);
var closing = new HostedCapture(settings, new CaptureOptions(), folder, handler);
handler.OnUpload = closing.Dispose;
await Throws<OperationCanceledException>(() => closing.CaptureAsync(video, 0, null, null, default));
MotionTests.Check(handler.Deleted && handler.Disposed, "Closing capture must cancel remotely before disposing HTTP");
await Throws<ObjectDisposedException>(() => closing.CaptureAsync(video, 0, null, null, default));
var remainingVideo = await File.ReadAllBytesAsync(video);
MotionTests.Check(bytes.SequenceEqual(remainingVideo), "The original video must remain unchanged");
}
finally { Directory.Delete(folder, true); }
Console.WriteLine("PASS: hosted upload/settings, queue positions, bounded Retry-After polling, result validation, cancellation and private metadata replacement");
}
sealed class HostHandler(string result, byte[] expectedVideo) : HttpMessageHandler
{
readonly Guid id = Guid.NewGuid();
internal bool Deleted, Created, Rejected, Disposed, Available = true;
internal int TransientPolls, QueuedPolls, PollCount, Position = 2;
internal double? Clip;
internal Action? OnUpload;
internal JsonElement Options;
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken token)
{
token.ThrowIfCancellationRequested();
MotionTests.Equal("host.example", request.RequestUri!.Host);
MotionTests.Equal(Key, request.Headers.Authorization!.Parameter);
var path = request.RequestUri.AbsolutePath;
if (Rejected) return new(HttpStatusCode.Unauthorized);
if (request.Method == HttpMethod.Delete) { Deleted = true; return new(HttpStatusCode.NoContent); }
if (path == "/prefix/v1/capabilities") return Json(new HostedCapabilities(1, Available, 1024, MaximumClipSeconds: Clip));
if (request.Method == HttpMethod.Post)
{
Created = true; Options = JsonSerializer.Deserialize<JsonElement>(await request.Content!.ReadAsStringAsync(token));
return Json(new HostedJob(id, "awaiting-upload", "Ready"));
}
if (request.Method == HttpMethod.Put)
{
MotionTests.Equal(".mp4", request.Headers.GetValues("X-Video-Extension").Single());
var uploadedVideo = await request.Content!.ReadAsByteArrayAsync(token);
MotionTests.Check(expectedVideo.SequenceEqual(uploadedVideo), "Upload bytes changed");
OnUpload?.Invoke(); return new(HttpStatusCode.Accepted);
}
if (path.EndsWith("/result", StringComparison.Ordinal))
return new(HttpStatusCode.OK) { Content = new StringContent(result, Encoding.UTF8, "application/json") };
PollCount++;
if (TransientPolls-- > 0)
{
var transient = new HttpResponseMessage(HttpStatusCode.ServiceUnavailable);
transient.Headers.RetryAfter = new RetryConditionHeaderValue(TimeSpan.FromSeconds(2)); return transient;
}
if (QueuedPolls-- > 0)
{
var queued = Json(new HostedJob(id, "queued", "Waiting", Position));
queued.Headers.RetryAfter = new RetryConditionHeaderValue(TimeSpan.FromSeconds(7)); return queued;
}
return Json(new HostedJob(id, "completed", "Ready"));
}
static HttpResponseMessage Json<T>(T value) => new(HttpStatusCode.OK)
{ Content = new StringContent(JsonSerializer.Serialize(value, new JsonSerializerOptions(JsonSerializerDefaults.Web)), Encoding.UTF8, "application/json") };
protected override void Dispose(bool disposing) { Disposed = true; base.Dispose(disposing); }
}
static async Task Throws<T>(Func<Task> action) where T : Exception
{
try { await action(); } catch (T) { return; }
throw new Exception("Expected " + typeof(T).Name);
}
}