using System.Net; using System.Text.Json; using FinalFactory.Rendezvous.Client; using FinalFactory.Rendezvous.Contracts; namespace FinalFactory.Rendezvous.Tests.Client; public sealed class RendezvousJoinClientTests { [Fact] public async Task JoinIssuanceRetriesTheSameIdempotentPayloadAndCancellationUsesCapability() { CreateJoinAttemptResponse created = CreateAttempt(); RecordingHandler handler = new( new HttpResponseMessage(HttpStatusCode.ServiceUnavailable), JsonResponse(HttpStatusCode.Created, created), new HttpResponseMessage(HttpStatusCode.NoContent)); using HttpClient http = new(handler) { BaseAddress = new("http://rendezvous.test/") }; RendezvousJoinClient client = new( http, new RendezvousClientOptions { JitterRatio = 0 }, new ImmediateDelay()); CreateJoinAttemptRequest request = new() { IdempotencyKey = "stable-join-key", GameId = new("space-game"), EnvironmentId = new("production"), ListingId = new(Guid.Parse("00000000-0000-0000-0000-000000000201")), ProtocolVersion = 7, }; RendezvousClientResult result = await client.CreateAsync(request); RendezvousClientResult cancelled = await client.CancelAsync(created); Assert.True(result.IsSuccess, result.Message); Assert.True(cancelled.IsSuccess, cancelled.Message); Assert.Equal(handler.Requests[0].Body, handler.Requests[1].Body); Assert.Contains("stable-join-key", handler.Requests[0].Body, StringComparison.Ordinal); RecordedRequest cancellation = handler.Requests[2]; Assert.Equal(HttpMethod.Delete, cancellation.Method); Assert.Equal( created.ClientPunchCapability, cancellation.Headers["X-Rendezvous-Client-Punch-Capability"]); } [Fact] public async Task HostInvitationPollingFollowsCursorsWithTheLeaseToken() { HostJoinAttempt first = CreateHostAttempt("00000000-0000-0000-0000-000000000211"); HostJoinAttempt second = CreateHostAttempt("00000000-0000-0000-0000-000000000212"); RecordingHandler handler = new( JsonResponse(HttpStatusCode.OK, new BrowseHostJoinAttemptsResponse { Items = [first], NextCursor = "next page+cursor", }), JsonResponse(HttpStatusCode.OK, new BrowseHostJoinAttemptsResponse { Items = [second], })); using HttpClient http = new(handler) { BaseAddress = new("http://rendezvous.test/") }; RendezvousJoinClient client = new(http); PublishedSession session = new(new RegisterSessionResponse { ListingId = new(Guid.Parse("00000000-0000-0000-0000-000000000220")), LeaseId = new(Guid.Parse("00000000-0000-0000-0000-000000000221")), LeaseToken = "lease-secret", HostPresenceHandle = new(Guid.Parse("00000000-0000-0000-0000-000000000222")), HostPresenceCapability = Credential('P'), ExpiresAt = new DateTimeOffset(2030, 1, 1, 0, 0, 0, TimeSpan.Zero), LeaseRenewAfterSeconds = 15, HostPresenceRefreshAfterSeconds = 10, }); RendezvousClientResult> result = await client.BrowseAllForHostAsync(session); Assert.True(result.IsSuccess, result.Message); Assert.Equal([first.AttemptId, second.AttemptId], result.Value!.Select(item => item.AttemptId)); Assert.Equal(2, handler.Requests.Count); Assert.All(handler.Requests, request => Assert.Equal("lease-secret", request.Headers["X-Rendezvous-Lease-Token"])); Assert.Contains("cursor=next%20page%2Bcursor", handler.Requests[1].Uri.Query, StringComparison.Ordinal); } [Fact] public async Task OutcomeReportingUsesTheAttemptCapabilityAndCoarseElapsedBucket() { CreateJoinAttemptResponse attempt = CreateAttempt(); RecordingHandler handler = new(JsonResponse(HttpStatusCode.OK, new ReportConnectionOutcomeResponse { Accepted = true, IsDuplicate = false, })); using HttpClient http = new(handler) { BaseAddress = new("http://rendezvous.test/") }; RendezvousJoinClient client = new(http); RendezvousConnectionOutcome outcome = RendezvousConnectionOutcome.Create( ConnectionOutcomeKind.DirectConnectTimedOut, RendezvousConnectionOutcomeSource.LocalTraversal, RendezvousConnectionFailureCategory.DirectConnection, RendezvousConnectionPhase.DirectConnection, TimeSpan.FromSeconds(6)); RendezvousClientResult result = await client.ReportOutcomeAsync(attempt, outcome); Assert.True(result.IsSuccess, result.Message); RecordedRequest request = Assert.Single(handler.Requests); Assert.Equal(HttpMethod.Post, request.Method); Assert.Equal( attempt.ClientPunchCapability, request.Headers["X-Rendezvous-Client-Punch-Capability"]); Assert.Contains("\"outcome\":\"directConnectTimedOut\"", request.Body, StringComparison.Ordinal); Assert.Contains("\"elapsedBucket\":\"fiveToFifteenSeconds\"", request.Body, StringComparison.Ordinal); Assert.DoesNotContain("diagnostic", request.Body, StringComparison.OrdinalIgnoreCase); } [Fact] public async Task ConnectionStartReturnsATypedServiceOutcomeInsteadOfAnUnboundedFailure() { RecordingHandler handler = new(JsonResponse(HttpStatusCode.NotFound, new ApiError { Code = RendezvousErrorCode.NotFound, Message = "listing unavailable", })); using HttpClient http = new(handler) { BaseAddress = new("http://rendezvous.test/") }; RendezvousJoinClient client = new(http); NetworkEndpoint fallback = new() { AddressFamily = AddressFamilyKind.Ipv4, Address = "203.0.113.93", Port = 9_063, }; RendezvousConnectionStartResult result = await client.CreateConnectionAttemptAsync( new CreateJoinAttemptRequest { IdempotencyKey = "typed-start", GameId = new("space-game"), EnvironmentId = new("production"), ListingId = new(Guid.Parse("00000000-0000-0000-0000-000000000230")), ProtocolVersion = 7, }, fallback); Assert.True(result.IsCompleted); Assert.False(result.IsReadyForTraversal); Assert.Null(result.Attempt); Assert.Equal(ConnectionOutcomeKind.DirectoryNotFound, result.Outcome!.Kind); Assert.Equal("203.0.113.93", result.Outcome.DedicatedFallback!.Address); } [Fact] public async Task ConnectionStartReturnsCancelledForAPrecancelledCallerToken() { RecordingHandler handler = new(); using HttpClient http = new(handler) { BaseAddress = new("http://rendezvous.test/") }; RendezvousJoinClient client = new(http); using CancellationTokenSource cancellation = new(); cancellation.Cancel(); RendezvousConnectionStartResult result = await client.CreateConnectionAttemptAsync( CreateRequest("cancelled-before-send"), new NetworkEndpoint { AddressFamily = AddressFamilyKind.Ipv4, Address = "203.0.113.90", Port = 7777, }, cancellationToken: cancellation.Token); Assert.Empty(handler.Requests); Assert.Equal(ConnectionOutcomeKind.Cancelled, result.Outcome!.Kind); Assert.True(result.Outcome.HasDedicatedFallback); Assert.Equal(RendezvousConnectionOutcomeSource.Caller, result.Outcome.Source); } [Fact] public async Task ConnectionStartReturnsCancelledWhenCallerStopsASilentRequest() { CancellingHandler handler = new(); using HttpClient http = new(handler) { BaseAddress = new("http://rendezvous.test/") }; RendezvousJoinClient client = new(http); using CancellationTokenSource cancellation = new(); Task pending = client.CreateConnectionAttemptAsync( CreateRequest("cancelled-in-flight"), cancellationToken: cancellation.Token); await handler.Started.Task.WaitAsync(TimeSpan.FromSeconds(2)); await cancellation.CancelAsync(); RendezvousConnectionStartResult result = await pending; Assert.Equal(ConnectionOutcomeKind.Cancelled, result.Outcome!.Kind); Assert.Equal(RendezvousConnectionOutcomeSource.Caller, result.Outcome.Source); } private static CreateJoinAttemptResponse CreateAttempt() => new() { AttemptId = new(Guid.Parse("00000000-0000-0000-0000-000000000202")), MediationHandle = new(Guid.Parse("00000000-0000-0000-0000-000000000203")), ClientPunchCapability = Credential('C'), ConnectionTicketDigest = NatIntroductionTokenCodec.ComputeDigest( NatIntroductionTokenCodec.Encode( new JoinAttemptId(Guid.Parse("00000000-0000-0000-0000-000000000202")), Credential('T'))), ExpiresAt = new DateTimeOffset(2030, 1, 1, 0, 0, 30, TimeSpan.Zero), }; private static CreateJoinAttemptRequest CreateRequest(string idempotencyKey) => new() { IdempotencyKey = idempotencyKey, GameId = new("space-game"), EnvironmentId = new("production"), ListingId = new(Guid.Parse("00000000-0000-0000-0000-000000000230")), ProtocolVersion = 7, }; private static HostJoinAttempt CreateHostAttempt(string id) => new() { AttemptId = new(Guid.Parse(id)), MediationHandle = new(Guid.NewGuid()), HostPunchCapability = Credential('H'), ConnectionTicketDigest = NatIntroductionTokenCodec.ComputeDigest( NatIntroductionTokenCodec.Encode(new JoinAttemptId(Guid.Parse(id)), Credential('T'))), ExpiresAt = new DateTimeOffset(2030, 1, 1, 0, 0, 30, TimeSpan.Zero), }; private static string Credential(char value) => new(value, ContractLimits.DerivedCredentialCharacters); private static HttpResponseMessage JsonResponse(HttpStatusCode status, T value) => new(status) { Content = new ByteArrayContent(JsonSerializer.SerializeToUtf8Bytes(value, ContractJson.Options)), }; private sealed class RecordingHandler(params HttpResponseMessage[] responses) : HttpMessageHandler { private readonly Queue _responses = new(responses); internal List Requests { get; } = []; protected override async Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { Dictionary headers = request.Headers.ToDictionary( static item => item.Key, static item => string.Join(",", item.Value), StringComparer.OrdinalIgnoreCase); Requests.Add(new( request.Method, request.RequestUri!, headers, request.Content is null ? string.Empty : await request.Content.ReadAsStringAsync(cancellationToken))); return _responses.Dequeue(); } } private sealed class CancellingHandler : HttpMessageHandler { internal TaskCompletionSource Started { get; } = new( TaskCreationOptions.RunContinuationsAsynchronously); protected override async Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { _ = request; Started.TrySetResult(); await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); throw new InvalidOperationException("The silent request unexpectedly completed."); } } private sealed record RecordedRequest( HttpMethod Method, Uri Uri, IReadOnlyDictionary Headers, string Body); private sealed class ImmediateDelay : IRendezvousDelay { public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); return Task.CompletedTask; } } }