using System.Net; using System.Text; using System.Text.Json; using FinalFactory.Rendezvous.Client; using FinalFactory.Rendezvous.Contracts; namespace FinalFactory.Rendezvous.Tests.Client; public sealed class RendezvousClientBehaviorTests { [Fact] public async Task RegistrationRetriesWithTheSameIdempotentPayloadAndDisposesResponses() { TrackingContent unavailable = JsonContent(new ApiError { Code = RendezvousErrorCode.ServiceUnavailable, Message = "try later", RetryAfterSeconds = 1, }); TrackingContent created = JsonContent(CreateRegistrationResponse()); ScriptedHandler handler = new( Response(HttpStatusCode.ServiceUnavailable, unavailable), Response(HttpStatusCode.Created, created), new HttpResponseMessage(HttpStatusCode.NoContent)); using HttpClient httpClient = new(handler) { BaseAddress = new("http://rendezvous.test/") }; RegisterSessionRequest request = CreateRegistrationRequest("stable-idempotency-key"); RecordingDelay delay = new(() => request.DisplayName = "mutated during retry delay"); RendezvousPublisherClient publisher = new( httpClient, new RendezvousClientOptions { JitterRatio = 0 }, delay); RendezvousClientResult result = await publisher.RegisterAsync( request, "publisher-credential"); Assert.True(result.IsSuccess); Assert.Equal(2, handler.RequestBodies.Count); Assert.Equal(handler.RequestBodies[0], handler.RequestBodies[1]); Assert.Contains("stable-idempotency-key", handler.RequestBodies[0], StringComparison.Ordinal); Assert.Equal(TimeSpan.FromSeconds(1), Assert.Single(delay.Delays)); Assert.True(unavailable.IsDisposed); Assert.True(created.IsDisposed); using HttpResponseMessage stillOwnedByCaller = await httpClient.GetAsync("health"); Assert.Equal(HttpStatusCode.NoContent, stillOwnedByCaller.StatusCode); } [Fact] public async Task UpdateUsesTheLeaseWithoutMutatingTheCallersRequest() { ScriptedHandler handler = new( Response(HttpStatusCode.Created, JsonContent(CreateRegistrationResponse())), new HttpResponseMessage(HttpStatusCode.NoContent)); using HttpClient httpClient = new(handler) { BaseAddress = new("http://rendezvous.test/") }; RendezvousPublisherClient publisher = new(httpClient); PublishedSession session = AssertSuccess(await publisher.RegisterAsync( CreateRegistrationRequest("update-idempotency-key"), "publisher-credential")); UpdateSessionRequest update = new() { LeaseToken = "caller-placeholder", BuildVersion = "2.0.0", DisplayName = "updated", Capacity = new() { CurrentPlayers = 2, MaximumPlayers = 4 }, Metadata = new() { ["mode"] = "online-coop" }, }; RendezvousClientResult result = await publisher.UpdateAsync( session, update, "publisher-credential"); Assert.True(result.IsSuccess); Assert.Equal("caller-placeholder", update.LeaseToken); Assert.Contains("lease-token", handler.RequestBodies[1], StringComparison.Ordinal); Assert.DoesNotContain("caller-placeholder", handler.RequestBodies[1], StringComparison.Ordinal); } [Fact] public async Task GatewayFailureIsRetriedForSafeBrowserReads() { ScriptedHandler handler = new( new HttpResponseMessage(HttpStatusCode.BadGateway), Response(HttpStatusCode.OK, JsonContent(new BrowseSessionsResponse()))); using HttpClient httpClient = new(handler) { BaseAddress = new("http://rendezvous.test/") }; RecordingDelay delay = new(); RendezvousSessionBrowserClient browser = new( httpClient, new RendezvousClientOptions { JitterRatio = 0 }, delay); RendezvousClientResult result = await browser.BrowseAsync(new() { GameId = new("space-game"), EnvironmentId = new("production"), ProtocolVersion = 7, }); Assert.True(result.IsSuccess, result.Message); Assert.Equal(2, handler.RequestUris.Count); Assert.Equal(TimeSpan.FromMilliseconds(200), Assert.Single(delay.Delays)); } [Fact] public async Task SilentServiceIsBoundedByTheConfiguredRequestTimeout() { using HttpClient httpClient = new(new SilentHandler()) { BaseAddress = new("http://rendezvous.test/"), }; RendezvousSessionBrowserClient browser = new( httpClient, new RendezvousClientOptions { MaximumSafeRetries = 0, RequestTimeout = TimeSpan.FromMilliseconds(20), JitterRatio = 0, }); RendezvousClientResult result = await browser.BrowseAsync(new() { GameId = new("space-game"), EnvironmentId = new("production"), ProtocolVersion = 7, }).WaitAsync(TimeSpan.FromSeconds(2)); Assert.Equal(RendezvousErrorCode.ServiceUnavailable, result.Error); } [Fact] public void SuccessResultRequiresAValue() { Assert.Throws(() => RendezvousClientResult.Success(null!)); } [Fact] public async Task BrowseAllFollowsCursorsWithoutMutatingTheCallersRequest() { ScriptedHandler handler = new( Response(HttpStatusCode.OK, JsonContent(new BrowseSessionsResponse { Items = [CreateListing("00000000-0000-0000-0000-000000000001")], NextCursor = "next page+token", })), Response(HttpStatusCode.OK, JsonContent(new BrowseSessionsResponse { Items = [CreateListing("00000000-0000-0000-0000-000000000002")], }))); using HttpClient httpClient = new(handler) { BaseAddress = new("http://rendezvous.test/") }; RendezvousSessionBrowserClient browser = new(httpClient); BrowseSessionsRequest request = new() { GameId = new("space-game"), EnvironmentId = new("production"), ProtocolVersion = 7, PageSize = 1, }; RendezvousClientResult> result = await browser.BrowseAllAsync(request); Assert.True(result.IsSuccess); Assert.Equal(2, result.Value!.Count); Assert.Null(request.Cursor); Assert.DoesNotContain("cursor=", handler.RequestUris[0].Query, StringComparison.Ordinal); Assert.Contains("cursor=next%20page%2Btoken", handler.RequestUris[1].Query, StringComparison.Ordinal); Assert.Contains("gameId=space-game", handler.RequestUris[0].Query, StringComparison.Ordinal); } [Fact] public async Task StreamRejectsMalformedAndOversizedEventEnvelopes() { string[] bodies = [ "event: session_upsert\nid: valid-cursor\ndata: {}\n\n", "data: " + new string('x', ContractLimits.SessionStreamEventMaxBytes + 1) + "\n\n", ]; foreach (string body in bodies) { StringContent content = new(body, Encoding.UTF8, "text/event-stream"); ScriptedHandler handler = new(Response(HttpStatusCode.OK, content)); using HttpClient httpClient = new(handler) { BaseAddress = new("http://rendezvous.test/"), }; RendezvousSessionBrowserClient browser = new(httpClient); await using IAsyncEnumerator> events = browser .StreamAsync(new BrowseSessionsRequest { GameId = new("space-game"), EnvironmentId = new("production"), ProtocolVersion = 7, }, "valid-stream-cursor") .GetAsyncEnumerator(); Assert.True(await events.MoveNextAsync()); Assert.False(events.Current.IsSuccess); Assert.Equal(RendezvousErrorCode.InternalError, events.Current.Error); } } [Fact] public async Task StreamRequiresANonEmptySnapshotCursor() { using HttpClient httpClient = new(new ScriptedHandler()) { BaseAddress = new("http://rendezvous.test/"), }; RendezvousSessionBrowserClient browser = new(httpClient); await Assert.ThrowsAsync(async () => { await foreach (RendezvousClientResult _ in browser.StreamAsync( new BrowseSessionsRequest { GameId = new("space-game"), EnvironmentId = new("production"), ProtocolVersion = 7, }, string.Empty)) { } }); } [Fact] public async Task StreamOpeningIsBoundedByTheConfiguredRequestTimeout() { using HttpClient httpClient = new(new SilentHandler()) { BaseAddress = new("http://rendezvous.test/"), }; RendezvousSessionBrowserClient browser = new( httpClient, new RendezvousClientOptions { MaximumSafeRetries = 0, RequestTimeout = TimeSpan.FromMilliseconds(20), }); await using IAsyncEnumerator> events = browser .StreamAsync(new BrowseSessionsRequest { GameId = new("space-game"), EnvironmentId = new("production"), ProtocolVersion = 7, }, "valid-stream-cursor") .GetAsyncEnumerator(); Assert.True(await events.MoveNextAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(2))); Assert.Equal(RendezvousErrorCode.ServiceUnavailable, events.Current.Error); } [Fact] public async Task LeaseMaintainerReportsLeaseLoss() { ScriptedHandler handler = new( Response(HttpStatusCode.Created, JsonContent(CreateRegistrationResponse())), Response(HttpStatusCode.Gone, JsonContent(new ApiError { Code = RendezvousErrorCode.Expired, Message = "lease expired", }))); using HttpClient httpClient = new(handler) { BaseAddress = new("http://rendezvous.test/") }; RecordingDelay delay = new(); RendezvousPublisherClient publisher = new(httpClient, delay: delay); PublishedSession session = AssertSuccess(await publisher.RegisterAsync( CreateRegistrationRequest("lease-loss-key"), "publisher-credential")); await using SessionLeaseMaintainer maintainer = publisher.CreateLeaseMaintainer( session, "publisher-credential"); bool eventRaised = false; maintainer.LeaseLost += (_, _) => eventRaised = true; LeaseMaintenanceResult result = await maintainer.RunAsync(); Assert.Equal(LeaseMaintenanceStopReason.LeaseLost, result.Reason); Assert.Equal(RendezvousErrorCode.Expired, result.Error); Assert.True(eventRaised); Assert.Equal(TimeSpan.FromSeconds(15), Assert.Single(delay.Delays)); } [Fact] public async Task DisposingLeaseMaintainerCancelsItsWaitAndDoesNotRenew() { ScriptedHandler handler = new( Response(HttpStatusCode.Created, JsonContent(CreateRegistrationResponse()))); using HttpClient httpClient = new(handler) { BaseAddress = new("http://rendezvous.test/") }; BlockingDelay delay = new(); RendezvousPublisherClient publisher = new(httpClient, delay: delay); PublishedSession session = AssertSuccess(await publisher.RegisterAsync( CreateRegistrationRequest("dispose-key"), "publisher-credential")); SessionLeaseMaintainer maintainer = publisher.CreateLeaseMaintainer( session, "publisher-credential"); Task active = maintainer.RunAsync(); await delay.Started.Task.WaitAsync(TimeSpan.FromSeconds(2)); await maintainer.DisposeAsync(); LeaseMaintenanceResult result = await active; Assert.Equal(LeaseMaintenanceStopReason.Disposed, result.Reason); Assert.Single(handler.RequestUris); await Assert.ThrowsAsync(() => maintainer.RunAsync()); } [Fact] public async Task CallerCancellationStopsLeaseMaintenanceWithoutRenewing() { ScriptedHandler handler = new( Response(HttpStatusCode.Created, JsonContent(CreateRegistrationResponse()))); using HttpClient httpClient = new(handler) { BaseAddress = new("http://rendezvous.test/") }; BlockingDelay delay = new(); RendezvousPublisherClient publisher = new(httpClient, delay: delay); PublishedSession session = AssertSuccess(await publisher.RegisterAsync( CreateRegistrationRequest("cancel-key"), "publisher-credential")); await using SessionLeaseMaintainer maintainer = publisher.CreateLeaseMaintainer( session, "publisher-credential"); using CancellationTokenSource cancellation = new(); Task active = maintainer.RunAsync(cancellation.Token); await delay.Started.Task.WaitAsync(TimeSpan.FromSeconds(2)); await cancellation.CancelAsync(); LeaseMaintenanceResult result = await active; Assert.Equal(LeaseMaintenanceStopReason.Cancelled, result.Reason); Assert.Single(handler.RequestUris); } private static T AssertSuccess(RendezvousClientResult result) { Assert.True(result.IsSuccess, result.Message); return Assert.IsType(result.Value); } private static RegisterSessionRequest CreateRegistrationRequest(string idempotencyKey) => new() { IdempotencyKey = idempotencyKey, GameId = new("space-game"), EnvironmentId = new("production"), RegionId = new("eu-central"), ProtocolVersion = 7, BuildVersion = "1.0.0", DisplayName = "SDK host", Visibility = ListingVisibility.Public, Capacity = new() { CurrentPlayers = 1, MaximumPlayers = 4 }, }; private static RegisterSessionResponse CreateRegistrationResponse() => new() { ListingId = new(Guid.Parse("00000000-0000-0000-0000-000000000010")), LeaseId = new(Guid.Parse("00000000-0000-0000-0000-000000000011")), LeaseToken = "lease-token", HostPresenceHandle = new(Guid.Parse("00000000-0000-0000-0000-000000000012")), HostPresenceCapability = "presence-capability", ExpiresAt = new DateTimeOffset(2030, 1, 1, 0, 0, 0, TimeSpan.Zero), LeaseRenewAfterSeconds = 15, HostPresenceRefreshAfterSeconds = 10, }; private static SessionListing CreateListing(string id) => new() { ListingId = new(Guid.Parse(id)), GameId = new("space-game"), EnvironmentId = new("production"), RegionId = new("eu-central"), ProtocolVersion = 7, BuildVersion = "1.0.0", DisplayName = "host", Visibility = ListingVisibility.Public, PublisherTrustMode = PublisherTrustMode.ManagedDedicated, Capacity = new() { CurrentPlayers = 1, MaximumPlayers = 4 }, }; private static TrackingContent JsonContent(T value) => new( JsonSerializer.SerializeToUtf8Bytes(value, ContractJson.Options)); private static HttpResponseMessage Response(HttpStatusCode status, HttpContent content) => new(status) { Content = content, }; private sealed class ScriptedHandler(params HttpResponseMessage[] responses) : HttpMessageHandler { private readonly Queue _responses = new(responses); internal List RequestBodies { get; } = []; internal List RequestUris { get; } = []; protected override async Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { RequestUris.Add(request.RequestUri!); RequestBodies.Add(request.Content is null ? string.Empty : await request.Content.ReadAsStringAsync(cancellationToken)); return _responses.Count > 0 ? _responses.Dequeue() : throw new InvalidOperationException("No scripted response remains."); } } private sealed class TrackingContent(byte[] bytes) : HttpContent { internal bool IsDisposed { get; private set; } protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) => stream.WriteAsync(bytes).AsTask(); protected override bool TryComputeLength(out long length) { length = bytes.Length; return true; } protected override void Dispose(bool disposing) { IsDisposed = true; base.Dispose(disposing); } } private sealed class RecordingDelay(Action? onDelay = null) : IRendezvousDelay { internal List Delays { get; } = []; public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); Delays.Add(delay); onDelay?.Invoke(); return Task.CompletedTask; } } private sealed class BlockingDelay : IRendezvousDelay { internal TaskCompletionSource Started { get; } = new( TaskCreationOptions.RunContinuationsAsynchronously); public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken) { Started.TrySetResult(); return Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); } } private sealed class SilentHandler : HttpMessageHandler { protected override async Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); return new HttpResponseMessage(HttpStatusCode.OK); } } }