Revisions

This commit is contained in:
2026-08-09 03:54:28 +03:00
parent 2028db671f
commit 3b55d8d2f8
37 changed files with 1743 additions and 230 deletions
+11 -9
View File
@@ -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")));
}
+3 -1
View File
@@ -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()
{
+20 -6
View File
@@ -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()
{
+44
View File
@@ -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;
}
}