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); } 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 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 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; } } }