462 lines
18 KiB
C#
462 lines
18 KiB
C#
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<PublishedSession> 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<bool> 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<BrowseSessionsResponse> 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<BrowseSessionsResponse> 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<ArgumentNullException>(() => RendezvousClientResult.Success<string>(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<IReadOnlyList<SessionListing>> 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<RendezvousClientResult<SessionStreamEvent>> 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<ArgumentException>(async () =>
|
|
{
|
|
await foreach (RendezvousClientResult<SessionStreamEvent> _ 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<RendezvousClientResult<SessionStreamEvent>> 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<LeaseMaintenanceResult> 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<ObjectDisposedException>(() => 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<LeaseMaintenanceResult> 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<T>(RendezvousClientResult<T> result)
|
|
{
|
|
Assert.True(result.IsSuccess, result.Message);
|
|
return Assert.IsType<T>(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>(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<HttpResponseMessage> _responses = new(responses);
|
|
|
|
internal List<string> RequestBodies { get; } = [];
|
|
internal List<Uri> RequestUris { get; } = [];
|
|
|
|
protected override async Task<HttpResponseMessage> 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<TimeSpan> 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<HttpResponseMessage> SendAsync(
|
|
HttpRequestMessage request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
|
|
return new HttpResponseMessage(HttpStatusCode.OK);
|
|
}
|
|
}
|
|
}
|