284 lines
12 KiB
C#
284 lines
12 KiB
C#
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<CreateJoinAttemptResponse> result = await client.CreateAsync(request);
|
|
RendezvousClientResult<bool> 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<IReadOnlyList<HostJoinAttempt>> 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<ReportConnectionOutcomeResponse> 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"),
|
|
cancellationToken: cancellation.Token);
|
|
|
|
Assert.Empty(handler.Requests);
|
|
Assert.Equal(ConnectionOutcomeKind.Cancelled, result.Outcome!.Kind);
|
|
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<RendezvousConnectionStartResult> 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<T>(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<HttpResponseMessage> _responses = new(responses);
|
|
|
|
internal List<RecordedRequest> Requests { get; } = [];
|
|
|
|
protected override async Task<HttpResponseMessage> SendAsync(
|
|
HttpRequestMessage request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
Dictionary<string, string> 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<HttpResponseMessage> 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<string, string> Headers,
|
|
string Body);
|
|
|
|
private sealed class ImmediateDelay : IRendezvousDelay
|
|
{
|
|
public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
return Task.CompletedTask;
|
|
}
|
|
}
|
|
}
|