Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .claude/skills/testing/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,10 @@ first. Never rerun a hung test "to see".
`AwaitResponseAsync(request, ...)` for request/response in tests.
- **`HubTestBase`** β€” message routing / layout tests; await the observable directly, e.g. `await hub.Observe<TResponse>(request).FirstAsync().Timeout(30.Seconds())` β€” never `.ToTask()`.
- **`MeshWeaver.Hosting.Orleans.TestBase`** β€” the core Orleans cluster machinery (test cluster,
disposal drain, shutdown-race suppression). The AI-flavoured Orleans rig (`OrleansTestBase`,
`OrleansSharedTestBase`, the swappable chat-client factory) ships with the AI engine in
disposal drain, shutdown-race suppression). `OrleansMeshTestBase` is the ONE base a suite
derives from; which cluster it gets is `Bootstrap => MeshBootstrap.Orleans(…)` plus
`SiloConfiguratorType`. The AI-flavoured Orleans rig (the derived fixture and the swappable
chat-client factory) ships with the AI engine in
**MeshWeaver.Plugins**, not here (#2276).

**Use `hub.Observe(...)`, not `RegisterCallback`/`AwaitResponse`** β€” those overloads are
Expand Down
570 changes: 570 additions & 0 deletions src/MeshWeaver.Documentation/Data/Architecture/DecentralisedTests.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ Registered once, `silo.AddMemoryStreams(StreamProviders.Memory)` in
Two facts that change the shape of the design, both verified from source:

1. **No production process hosts hubs as an Orleans client.** `UseOrleansMeshClient` has exactly
two callers, both test fixtures (`OrleansTestBase`, `OrleansDocumentationTest`). `Memex.Portal.Distributed`
two callers, both test fixtures (`OrleansMeshTestBase`, `OrleansDocumentationTest`). `Memex.Portal.Distributed`
is a co-hosted silo; the monolith, `LocalMesh` and the bake host run no Orleans at all. So *"a
client cannot host a grain, therefore it keeps the stream permanently"* β€” the standing
justification for the fallback β€” describes the **test rig**, not the fleet.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ a useful extra label, not the isolation mechanism.
For a test that creates a node via the client and then operates on it across the silo boundary:

1. **Fixture setup** β€” hold the shared backing store as a fixture instance field (above) and configure both silo and client containers to close over it.
2. **Test hub setup** β€” use `OrleansTestBase.GetClient(clientId?, userId)`. It is **synchronous** (there is no `GetClientAsync`) and it does the registration for you: `routingService.RegisterStream(client.Address, client.DeliverMessage)` at `client/{clientId}`. Don't target `Fixture.ClientMesh.Address`, whose hub is not the one registered for your test.
2. **Test hub setup** β€” use `OrleansMeshTestBase.GetClient(clientId?, userId)`. It is **synchronous** (there is no `GetClientAsync`) and it does the registration for you: `routingService.RegisterStream(client.Address, client.DeliverMessage)` at `client/{clientId}`. Don't target `Fixture.ClientMesh.Address`, whose hub is not the one registered for your test.
3. **Test message flow** β€” target every request at the registered hub's address. Responses route back through the memory-stream subscription that the registration created.
4. **Cross-silo operations** such as `workspace.GetMeshNodeStream(remotePath).Update(…)` work because the silo can resolve the remote path via the shared backing store, and the reply reaches the process-unique cache hub described above.

Expand All @@ -167,7 +167,7 @@ For a test that creates a node via the client and then operates on it across the

| Production | Test mirror |
|---|---|
| Each user circuit's `PortalApplication` creates `portal/{userId}` via `GetHostedHub` + auto-`RegisterStream`. | `OrleansTestBase.GetClient()` creates `client/{clientId}` and `RegisterStream`s it. |
| Each user circuit's `PortalApplication` creates `portal/{userId}` via `GetHostedHub` + auto-`RegisterStream`. | `OrleansMeshTestBase.GetClient()` creates `client/{clientId}` and `RegisterStream`s it. |
| All adapter instances (silo PG adapter + portal PG adapter) point at the same PG DB via shared connection string. | All `InMemoryStorageAdapter` instances (silo + client) close over one fixture-owned dictionary. |
| Silo dispatches portal-bound messages via Orleans memory stream keyed by `portal/{userId}`. | Silo dispatches test-bound responses via the same memory-stream mechanism, since the test hub subscribed at `client/{clientId}`. |
| Each process's `MeshNodeStreamCache` owns `cache/{meshHubId}`. | Same β€” silo and client each get their own, so replies cannot cross. |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ Disposing at test end tears down the per-node hub cleanly: the `ActionBlock` com

```csharp
public class MyOrleansTest(SharedOrleansFixture fixture, ITestOutputHelper output)
: OrleansTestBase(output), IAsyncLifetime
: OrleansMeshTestBase(output), IAsyncLifetime
Comment on lines 135 to +136
{
private readonly List<string> _createdPaths = [];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ Before writing a test, review the invariants every test must respect:
> Listing children, counting matches, "namespace is empty" β€” all legitimate uses of `Query`. Reading a specific node's *content* is not.

> **Rule 5 β€” No mocking of core services.**
> Never mock `IMessageHub`, `IMeshService`, or `IMeshStorage`. Inherit `MonolithMeshTestBase` or `OrleansTestBase` and run the real services. A mock that passes while production is broken is worse than no test.
> Never mock `IMessageHub`, `IMeshService`, or `IMeshStorage`. Inherit `MonolithMeshTestBase` or `OrleansMeshTestBase` and run the real services. A mock that passes while production is broken is worse than no test.

> **Rule 6 β€” Let failures propagate.**
> Timeouts, cancellations, and delivery failures are real test failures β€” the reactive assertion surface exposes them for you. Never wrap a read in `try { … } catch { return null; }`; that silently turns a flaky bug into a green-but-lying test. To assert an *expected* error, use `.Materialize()` (see below) rather than a swallowing `catch`.
Expand Down Expand Up @@ -160,7 +160,7 @@ public class MyFeatureTest(ITestOutputHelper output) : MonolithMeshTestBase(outp
- **`ReadNode(path)`** β€” the CQRS-correct single-node read, returning `IObservable<MeshNode?>`.
- **Clean teardown** β€” the mesh is disposed after every test.

`OrleansTestBase` (in `test/MeshWeaver.Hosting.Orleans.Test`) offers the same shape for distributed tests on an Orleans TestCluster.
`OrleansMeshTestBase` (in `test/MeshWeaver.Hosting.Orleans.TestBase`) offers the same shape for distributed tests on an Orleans TestCluster. It is the ONE Orleans base: which cluster a suite gets is a DECLARATION on the class (`protected override IMeshBootstrap Bootstrap => MeshBootstrap.Orleans(o => o.WithSilos(2));` and `SiloConfiguratorType`), not a choice of base class. The retired `OrleansTestBase<T>` / `OrleansSharedTestBase` survive only as a cross-repo bridge; do not derive from them.

> **Cold observables: `.Should()` is the subscribe.**
> `NodeFactory.CreateNode(...)`, `UpdateNode`, `DeleteNode`, and `hub.Observe(...)` are **cold** β€” the side effect (the write, the request dispatch) runs on subscribe, not on call. `await ….Should().Emit()` subscribes, performs the work, and waits for it to land. A bare `NodeFactory.CreateNode(node);` with no `.Should()` / `.Subscribe()` does nothing at all β€” and an **un-awaited** `.Should().Emit()` subscribes but does not wait, which is just as wrong.
Expand Down Expand Up @@ -383,7 +383,7 @@ config.TypeRegistry.WithType(typeof(MeshNodeReference), nameof(MeshNodeReference
return config.AddLayoutClient(); // GetDataRequest/Response + sub/unsub
```

The shared `OrleansTestBase` exposes a synchronous `GetClient(clientId?, userId)` that wires this up (it calls `routingService.RegisterStream(client.Address, client.DeliverMessage)`) β€” there is **no** async client-acquisition; the test calls `GetClient()` directly. Symptom of a missing registration: `await client.Observe(GetDataRequest(...)).Should().Emit()` never emits and the assertion times out.
The shared `OrleansMeshTestBase` exposes a synchronous `GetClient(clientId?, userId)` that wires this up (it calls `routingService.RegisterStream(client.Address, client.DeliverMessage)`) β€” there is **no** async client-acquisition; the test calls `GetClient()` directly. Symptom of a missing registration: `await client.Observe(GetDataRequest(...)).Should().Emit()` never emits and the assertion times out.

---

Expand All @@ -406,7 +406,7 @@ the Orleans suite the day this landed: **3 clusters booted instead of one per cl
attribute in a referenced base library registers nothing, silently: the pool then never
engages and every class quietly boots its own cluster again. The pool prints a one-line
receipt (`OrleansMeshPool: N cluster(s) booted`) so an inactive pool is visible, not inferred.
* `OrleansSharedTestBase` **leases** a running cluster (take-or-create, never wait β€” a waiting
* `OrleansMeshTestBase` **leases** a running cluster (take-or-create, never wait β€” a waiting
gate would be the hand-woven primitive `test/` forbids; the runner's own class-parallelism cap
bounds the pool). The lease is exclusive; on class teardown the cluster returns to the pool.
* **Isolation is by construction, not by process**: the seeded node repo is static and
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ namespace MeshWeaver.Documentation.Test;
///
/// <para><b>Why the allow file is EMPTY, and must stay that way.</b> Every site in the covered
/// surface was fixed in the change that added this guard β€” there were exactly four
/// (<c>MonolithMeshTestBase.DisposeTestClients</c>, <c>OrleansTestBase.DisposeAsync</c>,
/// (<c>MonolithMeshTestBase.DisposeTestClients</c>, <c>OrleansMeshTestBase.DisposeAsync</c>,
/// <c>SharedOrleansFixture.CleanupSiloHubsWithPrefix</c>, <c>PluginGateRunner</c>'s render client).
/// An allow file seeded with today's debt makes the debt permanent, so this one starts at zero and
/// may only ever be at zero: <b>adding a line to it is not a fix</b>, and the total budget below is
Expand Down
127 changes: 127 additions & 0 deletions test/MeshWeaver.Hosting.Monolith.Test/MeshBootstrapTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
using MeshWeaver.Hosting.Monolith.TestBase;
using Xunit;

namespace MeshWeaver.Hosting.Monolith.Test;

/// <summary>
/// The fluent bootstrap API, pinned WITHOUT a cluster.
///
/// <para>That is the point of the shape: the description of a mesh β€” in-process, or Orleans with
/// AdoNet membership and Redis grain storage across two silos β€” is a pure value, so the rules about
/// what can be stood up are checked here in milliseconds rather than discovered minutes into a silo
/// start-up as a connection error that names nothing about the test that asked for it.</para>
/// </summary>
public class MeshBootstrapTest
{
/// <summary>The default is the simple in-process mesh β€” what 23 of the 26 mesh-requiring
/// suites want, so it must be what you get for free.</summary>
[Fact]
public void Monolith_IsTheDefaultAndNeedsNoConfiguration()
{
var boot = MeshBootstrap.Monolith();
Assert.Equal("monolith", boot.Name);
Assert.Same(MonolithBootstrap.Instance, boot);
}

/// <summary>An Orleans bootstrap asked for with no options is the localhost, in-memory cluster
/// the current test base already builds β€” so adopting the seam changes nothing by itself.</summary>
[Fact]
public void Orleans_WithNoOptions_IsTheSelfContainedCluster()
{
var boot = Assert.IsType<OrleansBootstrap>(MeshBootstrap.Orleans());
Assert.Equal(ClusterProvider.Localhost, boot.Options.Clustering);
Assert.Equal(StorageProvider.Memory, boot.Options.Storage);
Assert.Equal((short)1, boot.Options.Silos);
Assert.Equal("orleans[localhost/memory, 1 silo]", boot.Name);
}

/// <summary>🚨 THE POINT OF THE FLUENT API: a suite says how its cluster is provisioned β€”
/// AdoNet membership, Redis grain storage, two silos β€” and the description survives verbatim.</summary>
[Fact]
public void Orleans_DescribesAdoNetMembershipAndRedisStorage()
{
var boot = Assert.IsType<OrleansBootstrap>(MeshBootstrap.Orleans(o => o
.WithClustering(ClusterProvider.AdoNet, "Server=localhost;Database=orleans")
.WithGrainStorage(StorageProvider.Redis, "localhost:6379")
.WithSilos(2)));

Assert.Equal(ClusterProvider.AdoNet, boot.Options.Clustering);
Assert.Equal("Server=localhost;Database=orleans", boot.Options.ClusteringConnectionString);
Assert.Equal(StorageProvider.Redis, boot.Options.Storage);
Assert.Equal("localhost:6379", boot.Options.StorageConnectionString);
Assert.Equal((short)2, boot.Options.Silos);
Assert.Equal("orleans[adonet/redis, 2 silos]", boot.Name);
}

/// <summary>The builder is fluent in both directions β€” order must not change the result.</summary>
[Fact]
public void Orleans_TheBuilderIsOrderIndependent()
{
var a = ((OrleansBootstrap)MeshBootstrap.Orleans(o => o
.WithSilos(3).WithGrainStorage(StorageProvider.AdoNet, "cs").WithClustering(ClusterProvider.Redis, "r"))).Options;
var b = ((OrleansBootstrap)MeshBootstrap.Orleans(o => o
.WithClustering(ClusterProvider.Redis, "r").WithGrainStorage(StorageProvider.AdoNet, "cs").WithSilos(3))).Options;
Assert.Equal(a, b);
}

/// <summary>
/// 🚨 A shape that cannot be stood up is refused WHERE IT IS WRITTEN. An external provider with
/// no connection string would otherwise reach a silo and fail there, minutes later, as a
/// connection error that says nothing about the test that asked for it.
/// </summary>
[Theory]
[InlineData(ClusterProvider.AdoNet, "needs a connection string")]
[InlineData(ClusterProvider.Redis, "needs a connection string")]
public void Orleans_AnExternalProviderWithoutAConnectionString_IsRefused(
ClusterProvider provider, string expected)
{
var ex = Assert.Throws<ArgumentException>(() =>
MeshBootstrap.Orleans(o => o.WithClustering(provider)));
Assert.Contains(expected, ex.Message);
Assert.Contains(provider.ToString(), ex.Message);
}

/// <summary>
/// The mirror case, and the one that actually misleads: a connection string handed to the
/// SELF-CONTAINED provider is silently ignored today, so a suite believes it is exercising a
/// real membership table and is not.
/// </summary>
[Fact]
public void Orleans_AConnectionStringOnLocalhostClustering_IsRefused()
{
var ex = Assert.Throws<ArgumentException>(() =>
MeshBootstrap.Orleans(o => o.WithClustering(ClusterProvider.Localhost, "Server=…")));
Assert.Contains("silently ignored", ex.Message);
}

/// <summary>A cluster of zero silos is not a cluster.</summary>
[Fact]
public void Orleans_ZeroSilos_IsRefused()
{
var ex = Assert.Throws<ArgumentException>(() => MeshBootstrap.Orleans(o => o.WithSilos(0)));
Assert.Contains("at least one silo", ex.Message);
}

/// <summary>
/// Without an applicator the Orleans bootstrap says SO, by name, instead of failing somewhere
/// inside a silo start-up. The monolith suites must never take an Orleans dependency, which is
/// why the cluster is described here and stood up elsewhere.
/// </summary>
[Fact]
public void Orleans_WithNoApplicator_SaysWhatIsMissing()
{
var previous = OrleansBootstrap.Applicator;
OrleansBootstrap.Applicator = null;
try
{
var boot = MeshBootstrap.Orleans(o => o.WithSilos(2));
var ex = Assert.Throws<InvalidOperationException>(() => boot.Bootstrap(null!));
Assert.Contains("No Orleans applicator is registered", ex.Message);
Assert.Contains("2 silos", ex.Message);
}
finally
{
OrleansBootstrap.Applicator = previous;
}
}
}
Loading
Loading