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
13 changes: 13 additions & 0 deletions .config/dotnet-tools.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-stryker": {
"version": "5.0.0",
"commands": [
"dotnet-stryker"
],
"rollForward": false
}
}
}
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,6 @@ TestResults/
.DS_Store

src/notes.txt

# Stryker.NET mutation-testing output
StrykerOutput/
1 change: 1 addition & 0 deletions Switchboard.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
<Project Path="src/Switchboard/Switchboard.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/Switchboard.IntegrationTests/Switchboard.IntegrationTests.csproj" />
<Project Path="tests/Switchboard.Tests/Switchboard.Tests.csproj" />
</Folder>
</Solution>
10 changes: 10 additions & 0 deletions stryker-config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"stryker-config": {
"project": "Switchboard.csproj",
"test-projects": [ "tests/Switchboard.Tests/Switchboard.Tests.csproj" ],
"target-framework": "net10.0",
"configuration": "Release",
"mutate": [ "**/*.cs" ],
"reporters": [ "progress", "cleartext", "html", "json" ]
}
}
48 changes: 48 additions & 0 deletions tests/Switchboard.IntegrationTests/AppDbContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;

namespace Switchboard.IntegrationTests;

public sealed class Note
{
public int Id { get; set; }

public string Text { get; set; } = "";

public string? CreatedBy { get; set; }
}

public sealed class AuditEntry
{
public int Id { get; set; }

public string Action { get; set; } = "";
}

/// <summary>The application's DbContext, registered scoped with <c>AddDbContext</c> as a Blazor Server app would.</summary>
public sealed class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options)
: base(options)
{
}

public DbSet<Note> Notes => Set<Note>();

public DbSet<AuditEntry> AuditEntries => Set<AuditEntry>();

/// <summary>Set once the owning DI scope has disposed this instance.</summary>
public bool IsDisposed { get; private set; }

public override void Dispose()
{
base.Dispose();
IsDisposed = true;
}

public override async ValueTask DisposeAsync()
{
await base.DisposeAsync();
IsDisposed = true;
}
}
82 changes: 82 additions & 0 deletions tests/Switchboard.IntegrationTests/CommandGate.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
using System;
using System.Data.Common;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore.Diagnostics;

namespace Switchboard.IntegrationTests;

/// <summary>
/// Holds one database command just before it executes, so a dispatch is provably mid-operation — inside EF Core's
/// critical section for that DbContext — while the test starts another one. Microsoft.Data.Sqlite completes its
/// async calls synchronously, so without this two "concurrent" dispatches would never actually overlap.
/// </summary>
/// <remarks>
/// <see cref="Arm"/> it, start the first dispatch, await <see cref="Held"/>, start the second, then <see cref="Release"/>.
/// Only the first matching command after arming is held; every other command passes straight through.
/// </remarks>
public sealed class CommandGate
{
// Guards against a hang if a test is wrong; never part of what a test asserts.
private static readonly TimeSpan HangGuard = TimeSpan.FromSeconds(30);

private readonly TaskCompletionSource<string> _held = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly TaskCompletionSource _released = new(TaskCreationOptions.RunContinuationsAsynchronously);
private string? _commandPrefix;
private int _armed;

/// <summary>Completes, with the command's text, once a command is being held.</summary>
public Task<string> Held => _held.Task.WaitAsync(HangGuard);

/// <summary>Holds the next command whose SQL starts with <paramref name="commandPrefix"/> (e.g. INSERT, SELECT).</summary>
public void Arm(string commandPrefix)
{
_commandPrefix = commandPrefix;
Volatile.Write(ref _armed, 1);
}

/// <summary>Lets the held command execute.</summary>
public void Release() => _released.TrySetResult();

internal async ValueTask WaitIfArmedAsync(DbCommand command, CancellationToken cancellationToken)
{
if (Volatile.Read(ref _armed) == 0 ||
!command.CommandText.TrimStart().StartsWith(_commandPrefix!, StringComparison.OrdinalIgnoreCase) ||
Interlocked.CompareExchange(ref _armed, 0, 1) != 1)
{
return;
}

_held.TrySetResult(command.CommandText);
await _released.Task.WaitAsync(HangGuard, cancellationToken);
}
}

/// <summary>Routes every async command through the <see cref="CommandGate"/>.</summary>
public sealed class GatingInterceptor : DbCommandInterceptor
{
private readonly CommandGate _gate;

public GatingInterceptor(CommandGate gate) => _gate = gate;

public override async ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result, CancellationToken cancellationToken = default)
{
await _gate.WaitIfArmedAsync(command, cancellationToken);
return result;
}

public override async ValueTask<InterceptionResult<int>> NonQueryExecutingAsync(
DbCommand command, CommandEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default)
{
await _gate.WaitIfArmedAsync(command, cancellationToken);
return result;
}

public override async ValueTask<InterceptionResult<object>> ScalarExecutingAsync(
DbCommand command, CommandEventData eventData, InterceptionResult<object> result, CancellationToken cancellationToken = default)
{
await _gate.WaitIfArmedAsync(command, cancellationToken);
return result;
}
}
84 changes: 84 additions & 0 deletions tests/Switchboard.IntegrationTests/ConcurrentDispatchTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using Xunit;

namespace Switchboard.IntegrationTests;

/// <summary>
/// The README's headline case: two dispatches from one Blazor Server circuit overlap — a click while a save is still
/// in flight. The circuit's scope is the whole connection, so without scope-per-dispatch both share its DbContext.
/// </summary>
public sealed class ConcurrentDispatchTests
{
/// <summary>The first sentence of EF Core's <c>ConcurrentMethodInvocation</c> error.</summary>
internal const string SecondOperation =
"A second operation was started on this context instance before a previous operation completed.";

[Fact]
public async Task Without_scope_per_dispatch_overlapping_sends_share_the_circuits_DbContext_and_EF_throws()
{
await using var host = await TestHost.StartAsync();
await using var circuit = host.OpenCircuit();

host.Gate.Arm("INSERT");
var first = circuit.Sender.Send(new CreateNote("first", "saved first"));
var heldCommand = await host.Gate.Held;

// The first dispatch is now inside SaveChangesAsync, its INSERT held just before it executes.
Assert.False(first.IsCompleted);
Assert.StartsWith("INSERT INTO \"Notes\"", heldCommand);

var second = circuit.Sender.Send(new CreateNote("second", "saved second"));

var error = await Assert.ThrowsAsync<InvalidOperationException>(() => second.Guarded());
Assert.StartsWith(SecondOperation, error.Message);

// Both handlers were handed the circuit's one DbContext.
Assert.Same(circuit.DbContext, host.Contexts["first"]);
Assert.Same(circuit.DbContext, host.Contexts["second"]);

host.Gate.Release();
await first.Guarded();

var saved = await host.WithFreshContextAsync(db => db.Notes.Select(n => n.Text).ToListAsync());
Assert.Equal(new[] { "saved first" }, saved);
}

[Fact]
public async Task With_scope_per_dispatch_overlapping_sends_each_get_their_own_DbContext_and_both_save()
{
await using var host = await TestHost.StartAsync(cfg => cfg.UseScopePerDispatch());
await using var circuit = host.OpenCircuit();

host.Gate.Arm("INSERT");
var first = circuit.Sender.Send(new CreateNote("first", "saved first"));
await host.Gate.Held;

// The same overlap as above: the first dispatch is mid-SaveChangesAsync while the second starts and finishes.
Assert.False(first.IsCompleted);

var secondId = await circuit.Sender.Send(new CreateNote("second", "saved second")).Guarded();
Assert.False(first.IsCompleted);

host.Gate.Release();
var firstId = await first.Guarded();

var firstDb = host.Contexts["first"];
var secondDb = host.Contexts["second"];
Assert.NotSame(firstDb, secondDb);
Assert.NotSame(circuit.DbContext, firstDb);
Assert.NotSame(circuit.DbContext, secondDb);

// Each dispatch scope, and its DbContext, ended with its dispatch.
Assert.True(firstDb.IsDisposed);
Assert.True(secondDb.IsDisposed);
Assert.False(circuit.DbContext.IsDisposed);

var saved = await host.WithFreshContextAsync(db => db.Notes.OrderBy(n => n.Id).Select(n => new { n.Id, n.Text }).ToListAsync());
Assert.Equal(
new[] { new { Id = secondId, Text = "saved second" }, new { Id = firstId, Text = "saved first" } },
saved);
}
}
92 changes: 92 additions & 0 deletions tests/Switchboard.IntegrationTests/CurrentUserTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
using System.Linq;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Xunit;

namespace Switchboard.IntegrationTests;

/// <summary>
/// A dispatch scope is new, so scoped state from the circuit — who is signed in — has to be carried into it by the
/// <c>UseScopePerDispatch</c> callback. Mirrors the README's <c>AuthenticationStateProvider</c> example.
/// </summary>
public sealed class CurrentUserTests
{
private static SwitchboardConfiguration CarryCurrentUser(SwitchboardConfiguration cfg)
=> cfg.UseScopePerDispatch(async (scope, cancellationToken) =>
{
var auth = await scope.Parent.GetRequiredService<CircuitAuthenticationState>().GetAuthenticationStateAsync();
scope.ServiceProvider.GetRequiredService<CurrentUser>().Set(auth);
});

private static Task<string?> CreatedBy(TestHost host, int noteId)
=> host.WithFreshContextAsync(db => db.Notes.Where(n => n.Id == noteId).Select(n => n.CreatedBy).SingleAsync());

[Fact]
public async Task The_callback_carries_the_circuits_user_into_the_dispatch_and_the_handler_stamps_it()
{
await using var host = await TestHost.StartAsync(cfg => CarryCurrentUser(cfg));
await using var circuit = host.OpenCircuit("alice");

var noteId = await circuit.Sender.Send(new CreateNote("alice's", "stamped"));

Assert.Equal("alice", await CreatedBy(host, noteId));
}

[Fact]
public async Task The_synchronous_callback_carries_the_user_too()
{
await using var host = await TestHost.StartAsync(cfg => cfg.UseScopePerDispatch(scope =>
scope.ServiceProvider.GetRequiredService<CurrentUser>().Set(
scope.Parent.GetRequiredService<CircuitAuthenticationState>().User)));
await using var circuit = host.OpenCircuit("alice");

var noteId = await circuit.Sender.Send(new CreateNote("alice's", "stamped"));

Assert.Equal("alice", await CreatedBy(host, noteId));
}

[Fact]
public async Task Overlapping_dispatches_from_two_circuits_each_stamp_their_own_user()
{
await using var host = await TestHost.StartAsync(cfg => CarryCurrentUser(cfg));
await using var alice = host.OpenCircuit("alice");
await using var bob = host.OpenCircuit("bob");

host.Gate.Arm("INSERT");
var alicesSave = alice.Sender.Send(new CreateNote("alice's", "by alice"));
await host.Gate.Held;

var bobsNote = await bob.Sender.Send(new CreateNote("bob's", "by bob")).Guarded();

host.Gate.Release();
var alicesNote = await alicesSave.Guarded();

Assert.Equal("alice", await CreatedBy(host, alicesNote));
Assert.Equal("bob", await CreatedBy(host, bobsNote));
}

[Fact]
public async Task Without_the_callback_the_dispatch_scope_has_no_user()
{
await using var host = await TestHost.StartAsync(cfg => cfg.UseScopePerDispatch());
await using var circuit = host.OpenCircuit("alice");

var noteId = await circuit.Sender.Send(new CreateNote("alice's", "not stamped"));

// The circuit's CurrentUser knows alice, but the handler got the dispatch scope's fresh, empty one.
Assert.Equal("alice", circuit.CurrentUser.Name);
Assert.Null(await CreatedBy(host, noteId));
}

[Fact]
public async Task Without_scope_per_dispatch_the_handler_sees_the_circuits_user_directly()
{
await using var host = await TestHost.StartAsync();
await using var circuit = host.OpenCircuit("alice");

var noteId = await circuit.Sender.Send(new CreateNote("alice's", "stamped"));

Assert.Equal("alice", await CreatedBy(host, noteId));
}
}
Loading
Loading