mirror of
https://github.com/esiur/esiur-dotnet.git
synced 2026-09-08 10:10:49 +00:00
Revisions
This commit is contained in:
@@ -6,17 +6,19 @@ namespace Esiur.Tests.Unit;
|
||||
public sealed class EpPortTests
|
||||
{
|
||||
[Fact]
|
||||
public void ServerDoesNotAssignAProtocolPort()
|
||||
=> Assert.Equal(0, new EpServer().Port);
|
||||
public void ServerUsesTheProtocolDefaultPort()
|
||||
=> Assert.Equal(EpProtocol.DefaultPort, new EpServer().Port);
|
||||
|
||||
[Fact]
|
||||
public async Task ClientEndpointWithoutPortIsRejected()
|
||||
public void ClientEndpointWithoutPortUsesTheProtocolDefault()
|
||||
{
|
||||
var warehouse = new Warehouse();
|
||||
|
||||
var exception = await Assert.ThrowsAsync<FormatException>(async () =>
|
||||
await warehouse.Get<EpConnection>("ep://localhost/sys/resource"));
|
||||
|
||||
Assert.Contains("explicit port", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
var endpoint = new Uri("ep://localhost/sys/resource");
|
||||
Assert.Equal(EpProtocol.DefaultPort, EpConnection.ResolveEndpointPort(endpoint));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExplicitClientPortStillOverridesTheProtocolDefault()
|
||||
=> Assert.Equal(
|
||||
12345,
|
||||
EpConnection.ResolveEndpointPort(new Uri("ep://localhost:12345/sys/resource")));
|
||||
}
|
||||
|
||||
@@ -9,7 +9,9 @@ public partial class BeaconResource
|
||||
|
||||
// No [AutoDelivery]: subscribable by default — requires an explicit
|
||||
// Subscribe request before occurrences flow to a given connection.
|
||||
[Export] public event ResourceEventHandler<string>? Ping;
|
||||
[Export]
|
||||
[Historical]
|
||||
public event ResourceEventHandler<string>? Ping;
|
||||
|
||||
// Opts out via [AutoDelivery]: flows to every attached connection
|
||||
// unconditionally.
|
||||
|
||||
@@ -6,6 +6,110 @@ namespace Esiur.Tests.Unit.Integration;
|
||||
[Collection("Integration")]
|
||||
public class EventSubscriptionIntegrationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task PropertyAndEventNotifications_ShareOneOrderedResourceRevision()
|
||||
{
|
||||
await using var cluster = await StartClusterAsync(out var getBeacon).WaitAsync(TimeSpan.FromSeconds(10));
|
||||
var remote = await GetRemote(cluster);
|
||||
var beacon = getBeacon();
|
||||
var propertyRevisions = new List<ResourceCursor>();
|
||||
var eventRevisions = new List<ResourceCursor>();
|
||||
|
||||
remote.Instance.PropertyModified += info => propertyRevisions.Add(info.Cursor);
|
||||
remote.Instance.EventOccurred += info => eventRevisions.Add(info.Cursor);
|
||||
await remote.OnAsync("Ping", _ => { });
|
||||
|
||||
beacon.Fire("ping", "ordered");
|
||||
await WaitUntilAsync(
|
||||
() => propertyRevisions.Count == 1 && eventRevisions.Count == 1,
|
||||
TimeSpan.FromSeconds(3));
|
||||
|
||||
Assert.Equal(propertyRevisions[0].Generation, eventRevisions[0].Generation);
|
||||
Assert.Equal(propertyRevisions[0].Revision + 1, eventRevisions[0].Revision);
|
||||
Assert.Equal(eventRevisions[0], remote.Instance.Cursor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryJournal_ReturnsHistoricalEventAfterCursor()
|
||||
{
|
||||
await using var cluster = await StartClusterAsync(out var getBeacon).WaitAsync(TimeSpan.FromSeconds(10));
|
||||
var remote = await GetRemote(cluster);
|
||||
var beacon = getBeacon();
|
||||
var attachedAt = remote.Instance.Cursor;
|
||||
var ping = remote.Instance.Definition.GetEventDefByName("Ping");
|
||||
|
||||
beacon.Fire("ping", "retained");
|
||||
|
||||
var page = await remote.QueryJournal(new ResourceJournalQuery
|
||||
{
|
||||
After = attachedAt,
|
||||
Kind = ResourceJournalEntryKind.EventOccurred,
|
||||
MemberIndex = ping.Index,
|
||||
});
|
||||
|
||||
var entry = Assert.Single(page.Entries);
|
||||
Assert.False(page.CursorExpired);
|
||||
Assert.Equal(ResourceJournalEntryKind.EventOccurred, entry.Kind);
|
||||
Assert.Equal(ping.Index, entry.MemberIndex);
|
||||
Assert.Equal("retained", entry.Value);
|
||||
Assert.True(entry.Cursor.Revision > attachedAt.Revision);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HistoricalSubscription_ReplaysOccurrenceMissedDuringReconnect()
|
||||
{
|
||||
await using var cluster = await StartClusterAsync(out var getBeacon).WaitAsync(TimeSpan.FromSeconds(10));
|
||||
cluster.Connection.AutoReconnect = true;
|
||||
cluster.Connection.ReconnectInterval = 1;
|
||||
var remote = await GetRemote(cluster);
|
||||
var beacon = getBeacon();
|
||||
var received = new List<string>();
|
||||
|
||||
await remote.OnAsync("Ping", value => received.Add((string)value));
|
||||
beacon.Fire("ping", "before");
|
||||
await WaitUntilAsync(() => received.Count == 1, TimeSpan.FromSeconds(3));
|
||||
|
||||
foreach (var serverConnection in cluster.Server.Connections.ToArray())
|
||||
serverConnection.Destroy();
|
||||
|
||||
await WaitUntilAsync(() => !cluster.Connection.IsConnected, TimeSpan.FromSeconds(3));
|
||||
beacon.Fire("ping", "while-offline");
|
||||
await WaitUntilAsync(() => cluster.Connection.IsConnected, TimeSpan.FromSeconds(5));
|
||||
await WaitUntilAsync(() => received.Count == 2, TimeSpan.FromSeconds(5));
|
||||
|
||||
Assert.Equal(new[] { "before", "while-offline" }, received);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HistoricalSubscription_ReplaysEveryPageBeforeLiveDelivery()
|
||||
{
|
||||
const int occurrenceCount = 10_001;
|
||||
BeaconResource? beacon = null;
|
||||
await using var cluster = await IntegrationCluster.StartAsync(
|
||||
async warehouse =>
|
||||
{
|
||||
beacon = new BeaconResource();
|
||||
await warehouse.Put("sys/beacon", beacon);
|
||||
},
|
||||
resourceJournalCapacity: occurrenceCount + 1).WaitAsync(TimeSpan.FromSeconds(10));
|
||||
|
||||
for (var index = 0; index < occurrenceCount; index++)
|
||||
beacon!.Fire("ping", index.ToString());
|
||||
|
||||
var remote = await GetRemote(cluster);
|
||||
var received = new List<string>(occurrenceCount);
|
||||
await remote.OnFromAsync(
|
||||
"Ping",
|
||||
new ResourceCursor(remote.Instance.Generation, 0),
|
||||
value => received.Add((string)value));
|
||||
|
||||
await WaitUntilAsync(
|
||||
() => received.Count == occurrenceCount,
|
||||
TimeSpan.FromSeconds(10));
|
||||
Assert.Equal("0", received[0]);
|
||||
Assert.Equal((occurrenceCount - 1).ToString(), received[^1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task On_DeliversAutoDeliveredEventWithNoSubscribeNeeded()
|
||||
{
|
||||
@@ -60,6 +164,21 @@ public class EventSubscriptionIntegrationTests
|
||||
Assert.Equal(new[] { "x", "y" }, b); // neither received "z"
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OnAsync_CompletesAfterWireSubscriptionIsReady()
|
||||
{
|
||||
await using var cluster = await StartClusterAsync(out var getBeacon).WaitAsync(TimeSpan.FromSeconds(10));
|
||||
var remote = await GetRemote(cluster);
|
||||
var beacon = getBeacon();
|
||||
var received = new List<string>();
|
||||
|
||||
await remote.OnAsync("Ping", value => received.Add((string)value));
|
||||
beacon.Fire("ping", "immediate");
|
||||
|
||||
await WaitUntilAsync(() => received.Count == 1, TimeSpan.FromSeconds(3));
|
||||
Assert.Equal(new[] { "immediate" }, received);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NativePlusEquals_AutoSubscribesAndUnsubscribes()
|
||||
{
|
||||
|
||||
@@ -205,7 +205,11 @@ internal sealed class IntegrationCluster : IAsyncDisposable
|
||||
bool mismatchedSessionKeys = false,
|
||||
bool requireKeyRotation = false,
|
||||
bool allowAuthentication = true,
|
||||
bool registerServerAuthenticationProvider = true)
|
||||
bool registerServerAuthenticationProvider = true,
|
||||
bool anonymous = false,
|
||||
int resourceJournalCapacity = 10000,
|
||||
Action<EpServer> serverCreated = null,
|
||||
Func<Warehouse, Task> populateClient = null)
|
||||
{
|
||||
var port = NextAvailablePort();
|
||||
|
||||
@@ -217,10 +221,13 @@ internal sealed class IntegrationCluster : IAsyncDisposable
|
||||
if (encrypted || requireEncryption)
|
||||
serverWh.RegisterEncryptionProvider(new AesEncryptionProvider());
|
||||
|
||||
await serverWh.Put("sys", new MemoryStore());
|
||||
await serverWh.Put(
|
||||
"sys",
|
||||
new MemoryStore(new ResourceJournalBuffer(resourceJournalCapacity)));
|
||||
var server = await serverWh.Put("sys/server", new EpServer
|
||||
{
|
||||
Port = (ushort)port,
|
||||
AllowUnauthorizedAccess = anonymous,
|
||||
AllowedAuthenticationProviders = allowAuthentication
|
||||
? new[]
|
||||
{
|
||||
@@ -234,6 +241,7 @@ internal sealed class IntegrationCluster : IAsyncDisposable
|
||||
: Array.Empty<string>(),
|
||||
RequireEncryption = requireEncryption,
|
||||
});
|
||||
serverCreated?.Invoke(server);
|
||||
|
||||
await populate(serverWh);
|
||||
|
||||
@@ -246,6 +254,8 @@ internal sealed class IntegrationCluster : IAsyncDisposable
|
||||
mismatchedSessionKeys ? (byte)0x80 : (byte)0,
|
||||
requireKeyRotation)
|
||||
: new TestClientAuthProvider());
|
||||
if (populateClient is not null)
|
||||
await populateClient(cluster.ClientWarehouse);
|
||||
if (encrypted)
|
||||
cluster.ClientWarehouse.RegisterEncryptionProvider(new AesEncryptionProvider());
|
||||
|
||||
@@ -255,12 +265,16 @@ internal sealed class IntegrationCluster : IAsyncDisposable
|
||||
$"ep://localhost:{port}",
|
||||
new EpConnectionContext
|
||||
{
|
||||
AuthenticationMode = AuthenticationMode.InitializerIdentity,
|
||||
Identity = "tester",
|
||||
AuthenticationMode = anonymous
|
||||
? AuthenticationMode.None
|
||||
: AuthenticationMode.InitializerIdentity,
|
||||
Identity = anonymous ? null : "tester",
|
||||
AuthenticationProtocol = oneStepAuthentication
|
||||
? "one-step"
|
||||
: PasswordAuthenticationProvider.ProtocolName,
|
||||
Domain = "test",
|
||||
: anonymous
|
||||
? null
|
||||
: PasswordAuthenticationProvider.ProtocolName,
|
||||
Domain = anonymous ? null : "test",
|
||||
EncryptionMode = encrypted
|
||||
? encryptionMode
|
||||
: EncryptionMode.None,
|
||||
|
||||
@@ -3,13 +3,77 @@ namespace Esiur.Tests.Unit.Integration;
|
||||
using Esiur.Data;
|
||||
using Esiur.Net.Sockets;
|
||||
using Esiur.Protocol;
|
||||
using Esiur.Resource;
|
||||
using Esiur.Security.Authority.Providers;
|
||||
using Esiur.Security.Cryptography;
|
||||
using Esiur.Stores;
|
||||
using System.Net;
|
||||
|
||||
[Collection("Integration")]
|
||||
public class SessionHeadersIntegrationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task AcceptedConnection_RaisesReadyAndDisconnectedLifecycleEvents()
|
||||
{
|
||||
var ready = new TaskCompletionSource<EpConnection>(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var disconnected = new TaskCompletionSource<EpConnection>(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
await using var cluster = await IntegrationCluster
|
||||
.StartAsync(
|
||||
_ => Task.CompletedTask,
|
||||
serverCreated: server =>
|
||||
{
|
||||
server.ConnectionReady += connection => ready.TrySetResult(connection);
|
||||
server.ConnectionDisconnected += connection => disconnected.TrySetResult(connection);
|
||||
})
|
||||
.WaitAsync(TimeSpan.FromSeconds(10));
|
||||
|
||||
var accepted = await ready.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
Assert.True(accepted.Session.Authenticated);
|
||||
Assert.Equal("tester", accepted.Session.RemoteIdentity);
|
||||
|
||||
accepted.NetworkClose(accepted.Socket);
|
||||
Assert.Same(
|
||||
accepted,
|
||||
await disconnected.Task.WaitAsync(TimeSpan.FromSeconds(5)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcceptedConnection_CanFetchInitiatorResourceBidirectionally()
|
||||
{
|
||||
var fetched = new TaskCompletionSource<IResource>(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
await using var cluster = await IntegrationCluster
|
||||
.StartAsync(
|
||||
_ => Task.CompletedTask,
|
||||
serverCreated: server => server.ConnectionReady += connection =>
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
fetched.TrySetResult(await connection.Get("client/exported"));
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
fetched.TrySetException(exception);
|
||||
}
|
||||
}),
|
||||
populateClient: async warehouse =>
|
||||
{
|
||||
await warehouse.Put("client", new MemoryStore());
|
||||
await warehouse.Put("client/exported", new Node { Id = 818 });
|
||||
})
|
||||
.WaitAsync(TimeSpan.FromSeconds(10));
|
||||
|
||||
var proxy = Assert.IsType<EpResource>(
|
||||
await fetched.Task.WaitAsync(TimeSpan.FromSeconds(5)));
|
||||
dynamic exported = proxy;
|
||||
Assert.Equal(818, Convert.ToInt32(exported.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AuthenticatedHandshake_StoresTypedHeadersWithoutAuthenticationData()
|
||||
{
|
||||
@@ -234,6 +298,36 @@ public class SessionHeadersIntegrationTests
|
||||
Assert.Equal(203, Convert.ToInt32(result));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnonymousWebSocketTransport_AssignsSchemaDomain()
|
||||
{
|
||||
await using var cluster = await IntegrationCluster
|
||||
.StartAsync(
|
||||
async warehouse =>
|
||||
{
|
||||
await warehouse.Put("sys/websocket-anonymous", new EncryptedEchoResource());
|
||||
},
|
||||
useWebSocket: true,
|
||||
anonymous: true)
|
||||
.WaitAsync(TimeSpan.FromSeconds(10));
|
||||
|
||||
var remote = (EpResource)await Task.Run(async () =>
|
||||
await cluster.Connection.Get("sys/websocket-anonymous"))
|
||||
.WaitAsync(TimeSpan.FromSeconds(10));
|
||||
var function = remote.Instance.Definition
|
||||
.GetFunctionDefByName(nameof(EncryptedEchoResource.Echo));
|
||||
var result = await remote._Invoke(
|
||||
function.Index,
|
||||
new Map<byte, object>
|
||||
{
|
||||
[0] = 42,
|
||||
});
|
||||
|
||||
Assert.Equal(42, Convert.ToInt32(result));
|
||||
Assert.False(string.IsNullOrWhiteSpace(
|
||||
Assert.Single(cluster.Server.Connections).RemoteDomain));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WrongAuthenticatedSessionKey_FailsPromptlyDuringKeyConfirmation()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
using Esiur.Data;
|
||||
using Esiur.Resource;
|
||||
|
||||
namespace Esiur.Tests.Unit;
|
||||
|
||||
public sealed class RuntimeCasterRecordTests
|
||||
{
|
||||
[Fact]
|
||||
public void DynamicRecordMaterializesAsDeclaredRecordType()
|
||||
{
|
||||
var warehouse = new Warehouse();
|
||||
var nestedDefinition = warehouse.GetLocalTypeDefByType(typeof(NestedRecord));
|
||||
var targetDefinition = warehouse.GetLocalTypeDefByType(typeof(TargetRecord));
|
||||
var nested = new Esiur.Data.Record(nestedDefinition)
|
||||
{
|
||||
[nameof(NestedRecord.Name)] = "edge-1",
|
||||
};
|
||||
var source = new Esiur.Data.Record(targetDefinition)
|
||||
{
|
||||
[nameof(TargetRecord.Enabled)] = true,
|
||||
[nameof(TargetRecord.Count)] = (short)7,
|
||||
[nameof(TargetRecord.Nested)] = nested,
|
||||
};
|
||||
|
||||
var converted = Assert.IsType<TargetRecord>(
|
||||
RuntimeCaster.Cast(source, typeof(TargetRecord)));
|
||||
|
||||
Assert.True(converted.Enabled);
|
||||
Assert.Equal(7, converted.Count);
|
||||
Assert.Equal("edge-1", converted.Nested.Name);
|
||||
}
|
||||
|
||||
private sealed class TargetRecord : IRecord
|
||||
{
|
||||
public bool Enabled { get; set; }
|
||||
public int Count { get; set; }
|
||||
public NestedRecord Nested { get; set; } = new();
|
||||
}
|
||||
|
||||
private sealed class NestedRecord : IRecord
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user