diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 0000000..900b72d
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,25 @@
+---
+name: Bug report
+about: Something in Alba isn't behaving as documented
+title: ''
+labels: bug
+assignees: ''
+---
+
+**Describe the bug**
+A clear description of what's going wrong.
+
+**To reproduce**
+A minimal failing test or code sample:
+
+```csharp
+// your scenario here
+```
+
+**Expected behavior**
+What you expected to happen.
+
+**Environment**
+- Alba version:
+- .NET version:
+- Bootstrapping style (IHostBuilder / WebApplicationBuilder / WebApplicationFactory):
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 0000000..a4f5e57
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,20 @@
+---
+name: Feature request
+about: Suggest an improvement or new capability for Alba
+title: ''
+labels: enhancement
+assignees: ''
+---
+
+**What problem are you trying to solve?**
+Describe the testing scenario Alba doesn't cover well today.
+
+**Proposed solution**
+What you'd like the API to look like, if you have something in mind:
+
+```csharp
+// sketch of the ideal usage
+```
+
+**Alternatives considered**
+Any workarounds you're using now.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..d224e90
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,48 @@
+# Contributing to Alba
+
+Thanks for your interest in contributing!
+
+## Building and testing
+
+Alba builds with the .NET SDK pinned in `global.json` and uses a [Nuke](https://nuke.build) build:
+
+```bash
+# Compile everything (Release)
+./build.cmd # or ./build.sh on Linux/macOS
+
+# Run the full test suite (xUnit + NUnit + TUnit sample projects)
+./build.cmd Test
+```
+
+Or work directly with the solution at `src/Alba.sln` from your IDE or `dotnet` CLI. The main test
+project is `src/Alba.Testing`.
+
+Package versions are managed centrally in `src/Directory.Packages.props`.
+
+## Documentation
+
+The docs are a VitePress site under `docs/`, with code samples embedded from the test projects via
+[MarkdownSnippets](https://github.com/SimonCropp/MarkdownSnippets) `#region sample_*` markers. To run
+the docs locally:
+
+```bash
+npm install
+npm run docs
+```
+
+If you change a `sample_*` region in code, the corresponding docs page updates when the snippets
+tool runs.
+
+## Pull requests
+
+- Open an issue first for anything beyond a small fix so the approach can be discussed.
+- Add or update tests for behavior changes — `src/Alba.Testing` has acceptance-style coverage for
+ most features.
+- Breaking changes should include an entry in the current major-version changelog file (e.g.
+ `v9_CHANGELOG.md`) with a short migration snippet.
+- Target the `master` branch.
+
+## Getting help
+
+Questions are welcome on the [JasperFx Discord](https://discord.gg/WMxrvegf8H) or via GitHub
+discussions/issues.
diff --git a/build/_build.csproj b/build/_build.csproj
index e1a98e3..b376d16 100644
--- a/build/_build.csproj
+++ b/build/_build.csproj
@@ -2,7 +2,7 @@
Exe
- net9.0
+ net10.0
CS0649;CS0169;CA1050;CA1822;CA2211;IDE1006
..
@@ -12,7 +12,11 @@
-
+
+
+
+
diff --git a/build/build.cs b/build/build.cs
index ba49f0e..c3f07f3 100644
--- a/build/build.cs
+++ b/build/build.cs
@@ -71,9 +71,13 @@ class Build : NukeBuild
{
DotNetTest(_ => _
.SetProjectFile(Solution.Alba_Testing));
-
+
DotNetTest(_ => _
.SetProjectFile(Solution.NUnitSamples));
+
+ // TUnit runs on Microsoft.Testing.Platform, which the VSTest-based
+ // dotnet test no longer supports on the .NET 10 SDK
+ DotNet($"run --project {Solution.TUnitSamples.Path}");
});
Target NugetPack => _ => _
diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts
index 96600d3..e3cc9ff 100644
--- a/docs/.vitepress/config.ts
+++ b/docs/.vitepress/config.ts
@@ -74,6 +74,7 @@ function getScenarioSidebar() {
{ text: 'HTTP Headers', link: '/scenarios/headers' },
{ text: 'JSON Web Services', link: '/scenarios/json' },
{ text: 'Plain Text Web Services', link: '/scenarios/text' },
+ { text: 'Server-Sent Events', link: '/scenarios/sse' },
{ text: 'Xml Web Services', link: '/scenarios/xml' },
{ text: 'Sending Form Data', link: '/scenarios/formdata' },
{ text: 'Before and After Actions', link: '/scenarios/setup' },
diff --git a/docs/guide/bootstrapping.md b/docs/guide/bootstrapping.md
index ae75986..72dbd55 100644
--- a/docs/guide/bootstrapping.md
+++ b/docs/guide/bootstrapping.md
@@ -16,7 +16,7 @@ public async Task build_host_from_Program()
// Bootstrap your application just as your real application does
var hostBuilder = Program.CreateHostBuilder(Array.Empty());
- await using var host = new AlbaHost(hostBuilder);
+ await using var host = await AlbaHost.For(hostBuilder);
// Just as a sample, I'll run a scenario against
// a "hello, world" application's root url
@@ -31,8 +31,8 @@ public async Task build_host_from_Program()
::: tip
-There are both synchronous and asynchronous methods to bootstrap an `AlbaHost`. Depending on your test harness, I recommend using
-the asynchronous version whenever applicable.
+Bootstrapping an `AlbaHost` is asynchronous. Use your test framework's asynchronous
+initialization support (e.g. xUnit's `IAsyncLifetime`) to start the host.
:::
Or alternatively, you can use one of the Alba extension methods off of `IHostBuilder` to start an `AlbaHost` object in a fluent interface
diff --git a/docs/guide/extensions.md b/docs/guide/extensions.md
index 573e01b..8e1417c 100644
--- a/docs/guide/extensions.md
+++ b/docs/guide/extensions.md
@@ -13,24 +13,48 @@ public interface IAlbaExtension : IDisposable, IAsyncDisposable
///
/// Called during the initialization of an AlbaHost after the application is started,
/// so the application DI container is available. Useful for registering setup or teardown
- /// actions on an AlbaHOst
+ /// actions on an AlbaHost
///
///
///
Task Start(IAlbaHost host);
-
+
///
- /// Allow an extension to alter the application's
- /// IHostBuilder prior to starting the application
+ /// Allow an extension to alter the application under test before it starts.
+ /// Behaves identically for every AlbaHost bootstrapping style.
///
///
- ///
- IHostBuilder Configure(IHostBuilder builder);
+ void Configure(IAlbaHostBuilder builder)
+ {
+ }
+}
+
+///
+/// Hosting-model-agnostic configuration surface handed to Alba extensions
+/// before the application under test starts
+///
+public interface IAlbaHostBuilder
+{
+ ///
+ /// Add or replace services in the application under test
+ ///
+ void ConfigureServices(Action configure);
+
+ ///
+ /// Add configuration sources for the application under test. Sources added
+ /// here take precedence over the application's own configuration.
+ ///
+ void ConfigureConfiguration(Action configure);
}
```
-snippet source | anchor
+snippet source | anchor
+`Configure` runs before the application under test starts and behaves the same regardless of how the
+`AlbaHost` was bootstrapped (`IHostBuilder`, `WebApplicationBuilder`, or `WebApplicationFactory`). It
+has a default no-op implementation, so extensions that only need post-start logic can implement
+`Start` alone.
+
When you are initializing an `AlbaHost`, you can pass in an optional array of extensions like this sample from the security stub
testing:
@@ -72,3 +96,105 @@ var host = await AlbaHost.For(builder =>
```
snippet source | anchor
+
+## Time Travel with TimeProviderOverride
+
+Time-sensitive endpoints — token or session expiry, cache TTLs, scheduling, "valid until" business
+rules — need a deterministic clock. The `TimeProviderOverride` extension replaces the application's
+`TimeProvider` registration with a controllable [FakeTimeProvider](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.time.testing.faketimeprovider)
+on every bootstrapping style. The extension *is* the clock: pass it to `AlbaHost.For(...)`, then
+drive time from the test.
+
+
+
+```cs
+[Fact]
+public async Task freezing_and_advancing_the_clock()
+{
+ // The clock is both the extension and the controllable TimeProvider
+ var clock = new TimeProviderOverride(new DateTimeOffset(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
+
+ await using var host = await AlbaHost.For(clock);
+
+ // Time is frozen at the configured start instant
+ (await host.GetAsJson("/time"))!.UtcNow
+ .ShouldBe(new DateTimeOffset(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
+ (await host.GetAsJson("/time"))!.UtcNow
+ .ShouldBe(new DateTimeOffset(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
+
+ // Travel forward three days
+ clock.Advance(TimeSpan.FromDays(3));
+ (await host.GetAsJson("/time"))!.UtcNow
+ .ShouldBe(new DateTimeOffset(2030, 1, 4, 0, 0, 0, TimeSpan.Zero));
+
+ // Or pin the clock to any instant
+ clock.SetUtcNow(new DateTimeOffset(2031, 6, 15, 12, 0, 0, TimeSpan.Zero));
+ (await host.GetAsJson("/time"))!.UtcNow
+ .ShouldBe(new DateTimeOffset(2031, 6, 15, 12, 0, 0, TimeSpan.Zero));
+}
+```
+snippet source | anchor
+
+
+The clock is frozen: `GetUtcNow()` returns the same instant until the test moves it with
+`Advance(...)` or `SetUtcNow(...)`, or opts into automatic movement via `AutoAdvanceAmount`. Timers
+created from the provider (including `Task.Delay(..., timeProvider)` and cache expirations) fire
+when `Advance` crosses their due time.
+
+The override only affects code that resolves `TimeProvider` from the container. Calls to
+`DateTime.UtcNow` or a directly captured `TimeProvider.System` are not intercepted — that is
+inherent to the `TimeProvider` pattern, not specific to Alba.
+
+The extension itself doubles as a compact example of the extension model:
+
+
+
+```cs
+///
+/// Alba extension that replaces the application's registration
+/// with this controllable for time-travel testing. Pass an
+/// instance to AlbaHost.For(...), then drive time from the test with Advance(...) and
+/// SetUtcNow(...). Time is frozen unless AutoAdvanceAmount is set.
+///
+public sealed class TimeProviderOverride : FakeTimeProvider, IAlbaExtension
+{
+ ///
+ /// Starts the clock at FakeTimeProvider's default of 2000-01-01T00:00:00Z
+ ///
+ public TimeProviderOverride()
+ {
+ }
+
+ ///
+ /// Starts the clock at the supplied instant
+ ///
+ public TimeProviderOverride(DateTimeOffset startDateTime) : base(startDateTime)
+ {
+ }
+
+ void IAlbaExtension.Configure(IAlbaHostBuilder builder)
+ {
+ builder.ConfigureServices(services =>
+ {
+ services.RemoveAll();
+ services.AddSingleton(this);
+ });
+ }
+
+ Task IAlbaExtension.Start(IAlbaHost host)
+ {
+ return Task.CompletedTask;
+ }
+
+ void IDisposable.Dispose()
+ {
+ }
+
+ ValueTask IAsyncDisposable.DisposeAsync()
+ {
+ return ValueTask.CompletedTask;
+ }
+}
+```
+snippet source | anchor
+
diff --git a/docs/guide/gettingstarted.md b/docs/guide/gettingstarted.md
index 68dd2f5..1ec1bc3 100644
--- a/docs/guide/gettingstarted.md
+++ b/docs/guide/gettingstarted.md
@@ -9,7 +9,7 @@ built in [ASP.NET Core TestServer](https://docs.microsoft.com/en-us/aspnet/core/
You can certainly write integration tests by hand using the lower level `TestServer` and `HttpClient`, but you'll write much less code with Alba. Moreover, Alba *scenarios* were meant to be declarative to maximize the readability of the integration tests, making those tests much more valuable as living technical documentation.
::: tip
-As of 8.0+, Alba only supports .NET 8.0 or greater. You can still use older versions of Alba to test previous versions of ASP.NET Core.
+As of 9.0+, Alba only supports .NET 10.0 or greater. You can still use older versions of Alba to test previous versions of ASP.NET Core.
:::
## Alba Setup
diff --git a/docs/index.md b/docs/index.md
index d56ed11..d53ed25 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -22,5 +22,5 @@ features:
- icon: 🔓
title: Authorization Stubbing
details: Stop fighting with your authorization system. Modify the shape of your user at the test level.
-footer: MIT Licensed | Copyright © Jeremy D. Miller and contributors.
+footer: Apache 2.0 Licensed | Copyright © Jeremy D. Miller and contributors.
---
diff --git a/docs/scenarios/assertions.md b/docs/scenarios/assertions.md
index a6fdd48..eb633f4 100644
--- a/docs/scenarios/assertions.md
+++ b/docs/scenarios/assertions.md
@@ -82,8 +82,7 @@ public Task using_scenario_with_ContentShouldContain_declaration_happy_path()
{
router.Handlers["/one"] = c =>
{
- c.Response.Write("**just the marker**");
- return Task.CompletedTask;
+ return c.Response.WriteAsync("**just the marker**");
};
return host.Scenario(x =>
@@ -93,5 +92,5 @@ public Task using_scenario_with_ContentShouldContain_declaration_happy_path()
});
}
```
-snippet source | anchor
+snippet source | anchor
diff --git a/docs/scenarios/json.md b/docs/scenarios/json.md
index ef71ee8..1c603f2 100644
--- a/docs/scenarios/json.md
+++ b/docs/scenarios/json.md
@@ -1,7 +1,9 @@
# Sending and Checking Json
JSON serialization is done with the configured input and output formatters within the underlying application. This means that
-Alba can support systems using both System.Text.Json and Newtonsoft.Json.
+Alba can support systems using both System.Text.Json and Newtonsoft.Json. Alba picks the application's System.Text.Json or
+Newtonsoft.Json formatters even when other registered formatters (such as ASP.NET Core OData's) also advertise
+`application/json`, so the JSON helpers work in applications that mix OData and plain API controllers.
## Sending Json
@@ -47,6 +49,21 @@ public async Task send_json_minimal_api(IAlbaHost host)
snippet source | anchor
+## Sending Raw Json
+
+When you already have the JSON payload as a string — malformed-input tests, captured payloads,
+and the like — skip serialization and write it directly:
+
+```cs
+await host.Scenario(_ =>
+{
+ _.Post.RawJson("{\"name\": \"Max\", \"age\": 13}").ToUrl("/person");
+});
+```
+
+This writes the string to the request body verbatim and sets the `content-type` header to
+`application/json`.
+
## Reading Json
diff --git a/docs/scenarios/setup.md b/docs/scenarios/setup.md
index 1bc1f2d..6602797 100644
--- a/docs/scenarios/setup.md
+++ b/docs/scenarios/setup.md
@@ -1,11 +1,16 @@
# Before and After actions
-::: warning
-The Before/After actions are **not** additive. The last one specified is the only one executed.
-:::
+Alba lets you register actions that run immediately before or after each scenario's HTTP request for common setup or teardown
+work like setting up authentication credentials or tracing. Registrations are additive — every registered action runs for every scenario.
-Alba allows you to specify actions that run immediately before or after an HTTP request is executed for common setup or teardown
-work like setting up authentication credentials or tracing or whatever.
+There are two kinds of *before* actions:
+
+- `BeforeEachAsync(Func)` — asynchronous preparation that runs **before** the HTTP request is created. It receives the
+ `Scenario`, so it can do real asynchronous work (fetch a token, hit a database) and then modify the outgoing request through
+ `scenario.ConfigureHttpContext(...)` or helpers like `scenario.WithRequestHeader(...)` and `scenario.WithBearerToken(...)`.
+- `BeforeEach(Action)` — synchronous work that runs against the live `HttpContext` immediately before the request executes.
+
+All asynchronous prepare actions run first (in registration order), then all synchronous actions (in registration order).
Here's a sample:
@@ -26,8 +31,9 @@ system.AfterEach(context =>
// is executed
});
-// Asynchronously
-system.BeforeEachAsync(context =>
+// Asynchronously, before the HTTP request executes. Modify the
+// outgoing request itself through scenario.ConfigureHttpContext()
+system.BeforeEachAsync(scenario =>
{
// do something asynchronous here
return Task.CompletedTask;
@@ -39,5 +45,5 @@ system.AfterEachAsync(context =>
return Task.CompletedTask;
});
```
-snippet source | anchor
+snippet source | anchor
diff --git a/docs/scenarios/sse.md b/docs/scenarios/sse.md
new file mode 100644
index 0000000..43cb396
--- /dev/null
+++ b/docs/scenarios/sse.md
@@ -0,0 +1,88 @@
+# Server-Sent Events
+
+Alba can test [server-sent event](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events) endpoints
+two ways: finite streams through an ordinary scenario, and live streams through a dedicated streaming API. Events
+are surfaced as `SseItem` from `System.Net.ServerSentEvents`.
+
+## Finite Streams
+
+If the endpoint writes a bounded number of events and completes, a regular scenario works — the response is fully
+buffered like any other. Parse the buffered body with `ReadAsServerSentEvents()`:
+
+
+
+```cs
+public async Task read_a_finite_event_stream(IAlbaHost host)
+{
+ // The endpoint writes a bounded number of events and completes,
+ // so an ordinary scenario buffers the whole response
+ var result = await host.Scenario(x =>
+ {
+ x.Get.Url("/sse/finite");
+ });
+
+ // Parse the buffered body as server-sent events
+ var events = result.ReadAsServerSentEvents();
+
+ // Or deserialize each data payload with the application's
+ // JSON options
+ var typed = result.ReadAsServerSentEvents();
+}
+```
+snippet source | anchor
+
+
+The typed overload `ReadAsServerSentEvents()` deserializes each `data:` payload as JSON using the application's
+own `JsonSerializerOptions`, exactly like `ReadAsJson()` does. Both reads are repeatable.
+
+## Live Streams
+
+Endpoints that stream indefinitely (or that you want to observe mid-stream) can't be buffered. Use
+`StreamServerSentEvents` instead of `Scenario`:
+
+
+
+```cs
+public async Task stream_live_events(IAlbaHost host)
+{
+ using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
+
+ // Returns as soon as the response headers arrive;
+ // the response body is never buffered
+ await using var stream = await host.StreamServerSentEvents(x =>
+ {
+ x.Get.Url("/sse/infinite");
+ }, timeout.Token);
+
+ // Events are yielded as the application writes them
+ await foreach (var item in stream.ReadEvents(timeout.Token))
+ {
+ if (item.Data == "done") break;
+ }
+
+ // Disposing the stream aborts the request, cancelling the
+ // endpoint's HttpContext.RequestAborted token
+}
+```
+snippet source | anchor
+
+
+The call returns as soon as the application flushes its response headers, and `ReadEvents()` /
+`ReadEvents()` yield each event as the application writes it. The event stream can only be consumed once.
+
+Disposing the `SseStreamResult` aborts the in-flight request, which cancels the endpoint's
+`HttpContext.RequestAborted` token — a well-behaved streaming endpoint will observe that and stop. Any
+`AfterEach`/`AfterEachAsync` actions run at disposal with a `null` `HttpContext`.
+
+All `BeforeEach`/`BeforeEachAsync` actions and Alba's security extensions (such as `JwtSecurityStub`, including
+per-scenario claims via `WithClaim`) apply to streams exactly as they do to scenarios.
+
+## Limitations
+
+* Response assertions (`ContentShouldContain`, header assertions, custom assertions) are not supported with
+ `StreamServerSentEvents` and are rejected up front — assert on the streamed events instead. The expected status
+ code *is* honored: a mismatch (for example a 401 from a missed auth setup) throws with the actual response body
+ instead of presenting an empty stream.
+* The response must have the `text/event-stream` content type.
+* The `CancellationToken` argument to `StreamServerSentEvents` is the escape hatch for an endpoint that never
+ starts its response.
diff --git a/docs/scenarios/statuscode.md b/docs/scenarios/statuscode.md
index 4146f67..5cfda12 100644
--- a/docs/scenarios/statuscode.md
+++ b/docs/scenarios/statuscode.md
@@ -15,14 +15,20 @@ public async Task check_the_status(IAlbaHost system)
// Or a specific status code
_.StatusCodeShouldBe(403);
+ // Any status code between 200 and 299; this is the
+ // default expectation for every scenario
+ _.StatusCodeShouldBeSuccess();
+
// Ignore the status code altogether
_.IgnoreStatusCode();
});
}
```
-snippet source | anchor
+snippet source | anchor
Do note that by default, if you do not specify the expected status code, Alba assumes that
-the request should return 200 (OK) and will fail the scenario if a different status code is found. You
-can ignore that check with the `Scenario.IgnoreStatusCode()` method.
+the request should return a success status code (200-299) and will fail the scenario otherwise.
+Use `StatusCodeShouldBe(...)` or `StatusCodeShouldBeOk()` to require one exact status code, or
+`Scenario.IgnoreStatusCode()` to skip the check altogether. `StatusCodeShouldBeSuccess()`
+restores the default success-range expectation after either of those.
diff --git a/docs/scenarios/text.md b/docs/scenarios/text.md
index c7127ea..a5ded8a 100644
--- a/docs/scenarios/text.md
+++ b/docs/scenarios/text.md
@@ -69,3 +69,20 @@ public async Task send_text(IAlbaHost host)
Do note that this also sets the `content-length` header to the string length and
sets the `content-type` header of the request to "text/plain."
+
+## Sending Binary Data
+
+Raw bytes or a stream can be written directly to the request body:
+
+```cs
+await host.Scenario(_ =>
+{
+ _.Post.ByteArray(bytes).ToUrl("/upload").ContentType("application/octet-stream");
+
+ // or from any readable Stream
+ _.Post.Stream(stream).ToUrl("/upload").ContentType("application/octet-stream");
+});
+```
+
+Both set the `content-length` header from the written content; set the `content-type` header
+yourself as shown.
diff --git a/docs/scenarios/urls.md b/docs/scenarios/urls.md
index 5c3d2b1..c1190a7 100644
--- a/docs/scenarios/urls.md
+++ b/docs/scenarios/urls.md
@@ -16,10 +16,36 @@ public async Task specify_url(AlbaHost system)
_.Put.Url("/");
_.Post.Url("/");
_.Delete.Url("/");
+ _.Patch.Url("/");
_.Head.Url("/");
+ _.Query.Url("/");
});
}
```
-snippet source | anchor
+snippet source | anchor
+
+
+## Query string parameters
+
+Query string parameters can be appended individually or from the public properties and fields
+of an object:
+
+
+
+```cs
+public async Task query_string_parameters(AlbaHost system)
+{
+ await system.Scenario(_ =>
+ {
+ // Add individual query string parameters
+ _.Get.Url("/search").QueryString("q", "alba").QueryString("page", "2");
+
+ // Or append one parameter per public property or field
+ // of an object
+ _.Get.Url("/search").QueryString(new { q = "alba", page = 2 });
+ });
+}
+```
+snippet source | anchor
diff --git a/docs/scenarios/writingscenarios.md b/docs/scenarios/writingscenarios.md
index 752eb62..c02c1f7 100644
--- a/docs/scenarios/writingscenarios.md
+++ b/docs/scenarios/writingscenarios.md
@@ -106,7 +106,7 @@ public async Task get_happy_path()
snippet source | anchor
-So what just happened in that test? First off, the call to `new AlbaHost(IHostBuilder)` bootstraps your web application.
+So what just happened in that test? First off, the call to `AlbaHost.For(IHostBuilder)` bootstraps your web application.
The call to `host.GetAsJson("/math/add/3/4")` is performing these steps internally:
diff --git a/global.json b/global.json
new file mode 100644
index 0000000..512142d
--- /dev/null
+++ b/global.json
@@ -0,0 +1,6 @@
+{
+ "sdk": {
+ "version": "10.0.100",
+ "rollForward": "latestFeature"
+ }
+}
diff --git a/src/Alba.Testing/Acceptance/asserting_against_status_code.cs b/src/Alba.Testing/Acceptance/asserting_against_status_code.cs
index 43f575c..18438af 100644
--- a/src/Alba.Testing/Acceptance/asserting_against_status_code.cs
+++ b/src/Alba.Testing/Acceptance/asserting_against_status_code.cs
@@ -1,4 +1,5 @@
-using System.Net;
+using Microsoft.AspNetCore.Http;
+using System.Net;
using Shouldly;
namespace Alba.Testing.Acceptance
@@ -12,9 +13,7 @@ public Task using_scenario_with_StatusCodeShouldBe_happy_path()
{
c.Response.StatusCode = 200;
c.Response.ContentType("text/plain");
- c.Response.Write("Some text");
-
- return Task.CompletedTask;
+ return c.Response.WriteAsync("Some text");
};
return host.Scenario(x =>
@@ -31,9 +30,7 @@ public async Task using_scenario_with_StatusCodeShouldBe_sad_path()
{
c.Response.StatusCode = 200;
c.Response.ContentType("text/plain");
- c.Response.Write("Some text");
-
- return Task.CompletedTask;
+ return c.Response.WriteAsync("Some text");
};
var ex = await Exception.ShouldBeThrownBy(() =>
@@ -54,9 +51,7 @@ public async Task happily_blows_up_on_an_unexpected_500()
router.Handlers["/wrong/status/code"] = c =>
{
c.Response.StatusCode = 500;
- c.Response.Write("the error text");
-
- return Task.CompletedTask;
+ return c.Response.WriteAsync("the error text");
};
var ex = await fails(_ =>
@@ -64,20 +59,59 @@ public async Task happily_blows_up_on_an_unexpected_500()
_.Get.Url("/wrong/status/code");
});
- ex.Message.ShouldContain("Expected status code 200, but was 500");
+ ex.Message.ShouldContain("Expected a status code between 200 and 299, but was 500");
ex.Message.ShouldContain("the error text");
}
- [Fact]
- public async Task using_scenario_with_StatusCodeShouldBeSuccess_happy_path()
+ [Theory]
+ [InlineData(200)]
+ [InlineData(201)]
+ [InlineData(204)]
+ [InlineData(299)]
+ public Task success_status_codes_pass_by_default(int statusCode)
+ {
+ router.Handlers["/one"] = c =>
+ {
+ c.Response.StatusCode = statusCode;
+ c.Response.ContentType("text/plain");
+ return c.Response.WriteAsync("Some text");
+ };
+
+ return host.Scenario(x =>
+ {
+ x.Get.Url("/one");
+ });
+ }
+
+ [Theory]
+ [InlineData(200)]
+ [InlineData(201)]
+ [InlineData(204)]
+ [InlineData(299)]
+ public Task using_scenario_with_StatusCodeShouldBeSuccess_happy_path(int statusCode)
{
router.Handlers["/one"] = c =>
{
- c.Response.StatusCode = 204;
+ c.Response.StatusCode = statusCode;
c.Response.ContentType("text/plain");
- c.Response.Write("Some text");
+ return c.Response.WriteAsync("Some text");
+ };
+
+ return host.Scenario(x =>
+ {
+ x.Get.Url("/one");
+ x.StatusCodeShouldBeSuccess();
+ });
+ }
- return Task.CompletedTask;
+ [Fact]
+ public async Task using_scenario_with_StatusCodeShouldBeSuccess_sad_path()
+ {
+ router.Handlers["/one"] = c =>
+ {
+ c.Response.StatusCode = 500;
+ c.Response.ContentType("text/plain");
+ return c.Response.WriteAsync("Some text");
};
var ex = await Exception.ShouldBeThrownBy(() =>
@@ -89,18 +123,17 @@ public async Task using_scenario_with_StatusCodeShouldBeSuccess_happy_path()
});
});
+ ex.Message.ShouldContain("Expected a status code between 200 and 299, but was 500");
}
[Fact]
- public async Task using_scenario_with_StatusCodeShouldBeSuccess_sad_path()
+ public async Task explicit_status_code_expectation_wins_over_ignore()
{
router.Handlers["/one"] = c =>
{
- c.Response.StatusCode = 500;
+ c.Response.StatusCode = 200;
c.Response.ContentType("text/plain");
- c.Response.Write("Some text");
-
- return Task.CompletedTask;
+ return c.Response.WriteAsync("Some text");
};
var ex = await Exception.ShouldBeThrownBy(() =>
@@ -108,11 +141,12 @@ public async Task using_scenario_with_StatusCodeShouldBeSuccess_sad_path()
return host.Scenario(x =>
{
x.Get.Url("/one");
- x.StatusCodeShouldBeSuccess();
+ x.IgnoreStatusCode();
+ x.StatusCodeShouldBe(500);
});
});
- ex.Message.ShouldContain("Expected status code 200, but was 500");
+ ex.Message.ShouldContain("Expected status code 500, but was 200");
}
}
diff --git a/src/Alba.Testing/Acceptance/asserting_against_the_response_body_text.cs b/src/Alba.Testing/Acceptance/asserting_against_the_response_body_text.cs
index 945fba4..5bcc1b2 100644
--- a/src/Alba.Testing/Acceptance/asserting_against_the_response_body_text.cs
+++ b/src/Alba.Testing/Acceptance/asserting_against_the_response_body_text.cs
@@ -1,3 +1,4 @@
+using Microsoft.AspNetCore.Http;
using Shouldly;
namespace Alba.Testing.Acceptance
@@ -10,8 +11,7 @@ public Task using_scenario_with_ContentShouldContain_declaration_happy_path()
{
router.Handlers["/one"] = c =>
{
- c.Response.Write("**just the marker**");
- return Task.CompletedTask;
+ return c.Response.WriteAsync("**just the marker**");
};
return host.Scenario(x =>
@@ -28,8 +28,7 @@ public async Task using_scenario_with_ContentShouldContain_declaration_sad_path(
{
router.Handlers["/one"] = c =>
{
- c.Response.Write("**just the marker**");
- return Task.CompletedTask;
+ return c.Response.WriteAsync("**just the marker**");
};
var ex = await fails(x =>
@@ -46,8 +45,7 @@ public Task using_scenario_with_ContentShouldNotContain_declaration_happy_path()
{
router.Handlers["/one"] = c =>
{
- c.Response.Write("**just the marker**");
- return Task.CompletedTask;
+ return c.Response.WriteAsync("**just the marker**");
};
return host.Scenario(x =>
@@ -62,8 +60,7 @@ public async Task using_scenario_with_ContentShouldNotContain_declaration_sad_pa
{
router.Handlers["/one"] = c =>
{
- c.Response.Write("**just the marker**");
- return Task.CompletedTask;
+ return c.Response.WriteAsync("**just the marker**");
};
var ex = await Exception.ShouldBeThrownBy(() =>
@@ -89,9 +86,7 @@ public async Task exact_content_sad_path()
router.Handlers["/memory/hello"] = c =>
{
c.Response.ContentType("text/plain");
- c.Response.Write("some text");
-
- return Task.CompletedTask;
+ return c.Response.WriteAsync("some text");
};
var e = await Exception.ShouldBeThrownBy(() =>
@@ -113,9 +108,7 @@ public Task exact_content_happy_path()
router.Handlers["/memory/hello"] = c =>
{
c.Response.ContentType("text/plain");
- c.Response.Write("hello from the in memory host");
-
- return Task.CompletedTask;
+ return c.Response.WriteAsync("hello from the in memory host");
};
return host.Scenario(x =>
diff --git a/src/Alba.Testing/Acceptance/content_type_verifications.cs b/src/Alba.Testing/Acceptance/content_type_verifications.cs
index 085c369..c3e3436 100644
--- a/src/Alba.Testing/Acceptance/content_type_verifications.cs
+++ b/src/Alba.Testing/Acceptance/content_type_verifications.cs
@@ -1,4 +1,5 @@
-using Shouldly;
+using Microsoft.AspNetCore.Http;
+using Shouldly;
namespace Alba.Testing
{
@@ -11,9 +12,7 @@ public Task content_type_should_be_happy_path()
router.Handlers["/memory/hello"] = c =>
{
c.Response.ContentType("text/plain");
- c.Response.Write("some text");
-
- return Task.CompletedTask;
+ return c.Response.WriteAsync("some text");
};
return host.Scenario(_ =>
@@ -32,9 +31,7 @@ public async Task content_type_sad_path()
router.Handlers["/memory/hello"] = c =>
{
c.Response.ContentType("text/plain");
- c.Response.Write("Some text");
-
- return Task.CompletedTask;
+ return c.Response.WriteAsync("Some text");
};
var ex = await fails(_ =>
diff --git a/src/Alba.Testing/Acceptance/http_query_method.cs b/src/Alba.Testing/Acceptance/http_query_method.cs
new file mode 100644
index 0000000..546e99a
--- /dev/null
+++ b/src/Alba.Testing/Acceptance/http_query_method.cs
@@ -0,0 +1,22 @@
+using Shouldly;
+
+namespace Alba.Testing.Acceptance;
+
+public class http_query_method
+{
+ [Fact]
+ public async Task runs_a_query_request_with_a_body()
+ {
+ await using var host = await AlbaHost.For();
+
+ var result = await host.Scenario(_ =>
+ {
+ _.Query.Url("/api/query");
+ _.Body.TextIs("Blue");
+
+ _.ContentShouldBe("I ran a QUERY with value Blue");
+ });
+
+ result.Context.Request.Method.ShouldBe("QUERY");
+ }
+}
diff --git a/src/Alba.Testing/Acceptance/preferring_framework_json_formatters.cs b/src/Alba.Testing/Acceptance/preferring_framework_json_formatters.cs
new file mode 100644
index 0000000..eb364b9
--- /dev/null
+++ b/src/Alba.Testing/Acceptance/preferring_framework_json_formatters.cs
@@ -0,0 +1,78 @@
+using Alba.Serialization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.Formatters;
+using Microsoft.Extensions.DependencyInjection;
+using Shouldly;
+using WebApp.Controllers;
+
+namespace Alba.Testing.Acceptance;
+
+public class preferring_framework_json_formatters
+{
+ // Mimics Microsoft.AspNetCore.OData's formatters (GH-116): registered at index 0,
+ // advertising "application/json", refusing non-OData requests via CanRead/CanWriteResult,
+ // and blowing up when driven directly the way Alba drives formatters
+ private sealed class FakeODataInputFormatter : InputFormatter
+ {
+ public FakeODataInputFormatter()
+ {
+ SupportedMediaTypes.Add("application/json");
+ SupportedMediaTypes.Add("application/json;odata.metadata=minimal");
+ }
+
+ public override bool CanRead(InputFormatterContext context) => false;
+
+ public override Task ReadRequestBodyAsync(InputFormatterContext context)
+ => throw new UriFormatException("Invalid URI: simulated OData formatter failure");
+ }
+
+ private sealed class FakeODataOutputFormatter : OutputFormatter
+ {
+ public FakeODataOutputFormatter()
+ {
+ SupportedMediaTypes.Add("application/json");
+ SupportedMediaTypes.Add("application/json;odata.metadata=minimal");
+ }
+
+ public override bool CanWriteResult(OutputFormatterCanWriteContext context) => false;
+
+ public override Task WriteResponseBodyAsync(OutputFormatterWriteContext context)
+ => throw new UriFormatException("Invalid URI: simulated OData formatter failure");
+ }
+
+ private static Task createHost() =>
+ AlbaHost.For(x =>
+ {
+ x.ConfigureServices(services => services.PostConfigure(o =>
+ {
+ o.InputFormatters.Insert(0, new FakeODataInputFormatter());
+ o.OutputFormatters.Insert(0, new FakeODataOutputFormatter());
+ }));
+ });
+
+ [Fact]
+ public async Task json_round_trip_ignores_third_party_json_formatters_registered_first()
+ {
+ await using var host = await createHost();
+
+ var posted = await host
+ .PostJson(new OperationRequest { Type = OperationType.Multiply, One = 3, Two = 4 }, "/math")
+ .Receive();
+
+ posted.Answer.ShouldBe(12);
+
+ (await host.GetAsJson("/math/add/3/4"))!.Answer.ShouldBe(7);
+ }
+
+ [Fact]
+ public async Task mvc_strategy_still_selects_the_applications_newtonsoft_formatters()
+ {
+ // WebApp uses AddControllers().AddNewtonsoftJson(); the MVC strategy exists to
+ // respect the application's configured serializer, so ranking must land on Newtonsoft
+ await using var host = await createHost();
+
+ var strategy = ((AlbaHost)host).MvcStrategy.ShouldBeOfType();
+ strategy.InputFormatter.GetType().Name.ShouldBe("NewtonsoftJsonInputFormatter");
+ strategy.OutputFormatter.GetType().Name.ShouldBe("NewtonsoftJsonOutputFormatter");
+ }
+}
diff --git a/src/Alba.Testing/Acceptance/web_application_factory_usage.cs b/src/Alba.Testing/Acceptance/web_application_factory_usage.cs
index e3ff8fb..b77cec6 100644
--- a/src/Alba.Testing/Acceptance/web_application_factory_usage.cs
+++ b/src/Alba.Testing/Acceptance/web_application_factory_usage.cs
@@ -28,7 +28,7 @@ await host.Scenario(x =>
});
});
- ex.Message.ShouldContain("Expected status code 200, but was 500");
+ ex.Message.ShouldContain("Expected a status code between 200 and 299, but was 500");
}
public interface IService { }
public class ServiceA : IService { }
diff --git a/src/Alba.Testing/Alba.Testing.csproj b/src/Alba.Testing/Alba.Testing.csproj
index e916834..9a69c33 100644
--- a/src/Alba.Testing/Alba.Testing.csproj
+++ b/src/Alba.Testing/Alba.Testing.csproj
@@ -1,6 +1,6 @@
- net8.0;net9.0;net10.0
+ net10.0
false
Exe
enable
@@ -16,30 +16,20 @@
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
-
-
-
-
-
-
-
-
-
-
-
-
PreserveNewest
@@ -53,12 +43,6 @@
PreserveNewest
-
- SnapshotTesting.cs
-
-
- SnapshotTesting.cs
-
SnapshotTesting.cs
diff --git a/src/Alba.Testing/Assertions/AssertionRunner.cs b/src/Alba.Testing/Assertions/AssertionRunner.cs
index cc12c7b..b88bbcb 100644
--- a/src/Alba.Testing/Assertions/AssertionRunner.cs
+++ b/src/Alba.Testing/Assertions/AssertionRunner.cs
@@ -1,9 +1,18 @@
+using System.Text;
using Microsoft.AspNetCore.Http;
namespace Alba.Testing.Assertions
{
public static class AssertionRunner
{
+ // Test helper for writing to stub DefaultHttpContext responses, which
+ // are backed by plain MemoryStreams
+ public static void Write(this HttpResponse response, string content)
+ {
+ var bytes = Encoding.UTF8.GetBytes(content);
+ response.Body.Write(bytes, 0, bytes.Length);
+ }
+
public static ScenarioAssertionException Run(IScenarioAssertion assertion,
Action configuration)
{
diff --git a/src/Alba.Testing/Samples/Quickstart3.cs b/src/Alba.Testing/Samples/Quickstart3.cs
index d98a217..f006116 100644
--- a/src/Alba.Testing/Samples/Quickstart3.cs
+++ b/src/Alba.Testing/Samples/Quickstart3.cs
@@ -33,7 +33,7 @@ public async Task build_host_from_Program()
// Bootstrap your application just as your real application does
var hostBuilder = Program.CreateHostBuilder(Array.Empty());
- await using var host = new AlbaHost(hostBuilder);
+ await using var host = await AlbaHost.For(hostBuilder);
// Just as a sample, I'll run a scenario against
// a "hello, world" application's root url
diff --git a/src/Alba.Testing/Samples/ServerSentEvents.cs b/src/Alba.Testing/Samples/ServerSentEvents.cs
new file mode 100644
index 0000000..80992e5
--- /dev/null
+++ b/src/Alba.Testing/Samples/ServerSentEvents.cs
@@ -0,0 +1,49 @@
+using Alba.Testing.Sse;
+
+namespace Alba.Testing.Samples
+{
+ public class ServerSentEvents
+ {
+ #region sample_sse_buffered
+ public async Task read_a_finite_event_stream(IAlbaHost host)
+ {
+ // The endpoint writes a bounded number of events and completes,
+ // so an ordinary scenario buffers the whole response
+ var result = await host.Scenario(x =>
+ {
+ x.Get.Url("/sse/finite");
+ });
+
+ // Parse the buffered body as server-sent events
+ var events = result.ReadAsServerSentEvents();
+
+ // Or deserialize each data payload with the application's
+ // JSON options
+ var typed = result.ReadAsServerSentEvents();
+ }
+ #endregion
+
+ #region sample_sse_streaming
+ public async Task stream_live_events(IAlbaHost host)
+ {
+ using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
+
+ // Returns as soon as the response headers arrive;
+ // the response body is never buffered
+ await using var stream = await host.StreamServerSentEvents(x =>
+ {
+ x.Get.Url("/sse/infinite");
+ }, timeout.Token);
+
+ // Events are yielded as the application writes them
+ await foreach (var item in stream.ReadEvents(timeout.Token))
+ {
+ if (item.Data == "done") break;
+ }
+
+ // Disposing the stream aborts the request, cancelling the
+ // endpoint's HttpContext.RequestAborted token
+ }
+ #endregion
+ }
+}
diff --git a/src/Alba.Testing/Samples/SnapshotTesting.SnapshotTest.DotNet8_0.verified.txt b/src/Alba.Testing/Samples/SnapshotTesting.SnapshotTest.DotNet8_0.verified.txt
deleted file mode 100644
index 9a1abb7..0000000
--- a/src/Alba.Testing/Samples/SnapshotTesting.SnapshotTest.DotNet8_0.verified.txt
+++ /dev/null
@@ -1,19 +0,0 @@
-{
- Context: {
- Request: {
- Headers: {
- Accept: application/json,
- Content-Length: 67,
- Content-Type: application/json,
- Host: localhost
- }
- },
- IsAbortedRequested: false,
- Response: {
- StatusCode: OK,
- Headers: {
- Content-Type: application/json; charset=utf-8
- }
- }
- }
-}
\ No newline at end of file
diff --git a/src/Alba.Testing/Samples/SnapshotTesting.SnapshotTest.DotNet9_0.verified.txt b/src/Alba.Testing/Samples/SnapshotTesting.SnapshotTest.DotNet9_0.verified.txt
deleted file mode 100644
index 45d448d..0000000
--- a/src/Alba.Testing/Samples/SnapshotTesting.SnapshotTest.DotNet9_0.verified.txt
+++ /dev/null
@@ -1,18 +0,0 @@
-{
- Context: {
- Request: {
- Headers: {
- Accept: application/json,
- Content-Length: 67,
- Content-Type: application/json,
- Host: localhost
- }
- },
- Response: {
- StatusCode: OK,
- Headers: {
- Content-Type: application/json; charset=utf-8
- }
- }
- }
-}
diff --git a/src/Alba.Testing/Samples/StatusCodes.cs b/src/Alba.Testing/Samples/StatusCodes.cs
index 11d07a5..66203cf 100644
--- a/src/Alba.Testing/Samples/StatusCodes.cs
+++ b/src/Alba.Testing/Samples/StatusCodes.cs
@@ -13,6 +13,10 @@ await system.Scenario(_ =>
// Or a specific status code
_.StatusCodeShouldBe(403);
+ // Any status code between 200 and 299; this is the
+ // default expectation for every scenario
+ _.StatusCodeShouldBeSuccess();
+
// Ignore the status code altogether
_.IgnoreStatusCode();
});
diff --git a/src/Alba.Testing/Samples/Urls.cs b/src/Alba.Testing/Samples/Urls.cs
index ff54ec0..72d197b 100644
--- a/src/Alba.Testing/Samples/Urls.cs
+++ b/src/Alba.Testing/Samples/Urls.cs
@@ -13,7 +13,24 @@ await system.Scenario(_ =>
_.Put.Url("/");
_.Post.Url("/");
_.Delete.Url("/");
+ _.Patch.Url("/");
_.Head.Url("/");
+ _.Query.Url("/");
+ });
+ }
+ #endregion
+
+ #region sample_query_string_parameters
+ public async Task query_string_parameters(AlbaHost system)
+ {
+ await system.Scenario(_ =>
+ {
+ // Add individual query string parameters
+ _.Get.Url("/search").QueryString("q", "alba").QueryString("page", "2");
+
+ // Or append one parameter per public property or field
+ // of an object
+ _.Get.Url("/search").QueryString(new { q = "alba", page = 2 });
});
}
#endregion
diff --git a/src/Alba.Testing/ScenarioAssertionExceptionTests.cs b/src/Alba.Testing/ScenarioAssertionExceptionTests.cs
index d852a86..420162a 100644
--- a/src/Alba.Testing/ScenarioAssertionExceptionTests.cs
+++ b/src/Alba.Testing/ScenarioAssertionExceptionTests.cs
@@ -1,4 +1,5 @@
-using Alba.Internal;
+using Microsoft.AspNetCore.Http;
+using Alba.Internal;
using Shouldly;
namespace Alba.Testing;
@@ -10,8 +11,7 @@ public Task from_http_request_message_with_get_request()
{
router.Handlers["/api/test"] = c =>
{
- c.Response.Write("success");
- return Task.CompletedTask;
+ return c.Response.WriteAsync("success");
};
var request = new HttpRequestMessage(HttpMethod.Get, "http://localhost/api/test");
@@ -32,8 +32,7 @@ public Task from_http_request_message_with_post_request_and_content()
var body = c.Request.Body.ReadAllBytes();
var text = System.Text.Encoding.UTF8.GetString(body);
text.ShouldBe("test data");
- c.Response.Write("received");
- return Task.CompletedTask;
+ return c.Response.WriteAsync("received");
};
var request = new HttpRequestMessage(HttpMethod.Post, "http://localhost/api/data")
@@ -54,8 +53,7 @@ public Task from_http_request_message_with_put_request()
{
router.Handlers["/api/update"] = c =>
{
- c.Response.Write("updated");
- return Task.CompletedTask;
+ return c.Response.WriteAsync("updated");
};
var request = new HttpRequestMessage(HttpMethod.Put, "http://localhost/api/update");
@@ -73,8 +71,7 @@ public Task from_http_request_message_with_delete_request()
{
router.Handlers["/api/remove"] = c =>
{
- c.Response.Write("deleted");
- return Task.CompletedTask;
+ return c.Response.WriteAsync("deleted");
};
var request = new HttpRequestMessage(HttpMethod.Delete, "http://localhost/api/remove");
@@ -92,8 +89,7 @@ public Task from_http_request_message_with_patch_request()
{
router.Handlers["/api/patch"] = c =>
{
- c.Response.Write("patched");
- return Task.CompletedTask;
+ return c.Response.WriteAsync("patched");
};
var request = new HttpRequestMessage(HttpMethod.Patch, "http://localhost/api/patch");
@@ -126,8 +122,7 @@ public Task from_http_request_message_with_query_string()
router.Handlers["/api/search"] = c =>
{
c.Request.Query["q"].ToString().ShouldBe("test");
- c.Response.Write("found");
- return Task.CompletedTask;
+ return c.Response.WriteAsync("found");
};
var request = new HttpRequestMessage(HttpMethod.Get, "http://localhost/api/search?q=test");
@@ -146,8 +141,7 @@ public Task from_http_request_message_with_custom_headers()
router.Handlers["/api/headers"] = c =>
{
c.Request.Headers["X-Custom-Header"].ToString().ShouldBe("custom-value");
- c.Response.Write("ok");
- return Task.CompletedTask;
+ return c.Response.WriteAsync("ok");
};
var request = new HttpRequestMessage(HttpMethod.Get, "http://localhost/api/headers");
@@ -168,8 +162,7 @@ public Task from_http_request_message_with_bearer_token()
{
var auth = c.Request.Headers["Authorization"].ToString();
auth.ShouldBe("Bearer test-token-123");
- c.Response.Write("authorized");
- return Task.CompletedTask;
+ return c.Response.WriteAsync("authorized");
};
var request = new HttpRequestMessage(HttpMethod.Get, "http://localhost/api/secure");
@@ -192,8 +185,7 @@ public Task from_http_request_message_with_json_content()
var json = System.Text.Encoding.UTF8.GetString(body);
json.ShouldBe("{\"name\":\"test\"}");
c.Request.ContentType.ShouldBe("application/json; charset=utf-8");
- c.Response.Write("processed");
- return Task.CompletedTask;
+ return c.Response.WriteAsync("processed");
};
var request = new HttpRequestMessage(HttpMethod.Post, "http://localhost/api/json")
@@ -219,8 +211,7 @@ public Task from_http_request_message_with_byte_array_content()
var body = c.Request.Body.ReadAllBytes();
body.ShouldBe(bytes);
c.Request.ContentType.ShouldBe("image/png");
- c.Response.Write("uploaded");
- return Task.CompletedTask;
+ return c.Response.WriteAsync("uploaded");
};
var request = new HttpRequestMessage(HttpMethod.Post, "http://localhost/api/binary")
@@ -242,8 +233,7 @@ public Task from_http_request_message_with_relative_uri()
{
router.Handlers["/api/relative"] = c =>
{
- c.Response.Write("relative path");
- return Task.CompletedTask;
+ return c.Response.WriteAsync("relative path");
};
var request = new HttpRequestMessage(HttpMethod.Get, "/api/relative");
@@ -263,8 +253,7 @@ public Task from_http_request_message_with_multiple_headers()
{
c.Request.Headers["X-Custom-1"].ToString().ShouldBe("value1");
c.Request.Headers["X-Custom-2"].ToString().ShouldBe("value2");
- c.Response.Write("multi");
- return Task.CompletedTask;
+ return c.Response.WriteAsync("multi");
};
var request = new HttpRequestMessage(HttpMethod.Get, "http://localhost/api/multiheader");
@@ -325,8 +314,7 @@ public Task from_http_request_message_with_content_headers()
router.Handlers["/api/content-headers"] = c =>
{
c.Request.Headers["Content-Language"].ToString().ShouldBe("en-US");
- c.Response.Write("ok");
- return Task.CompletedTask;
+ return c.Response.WriteAsync("ok");
};
var request = new HttpRequestMessage(HttpMethod.Post, "http://localhost/api/content-headers")
diff --git a/src/Alba.Testing/ScenarioContext.cs b/src/Alba.Testing/ScenarioContext.cs
index 865d1b7..0f0e296 100644
--- a/src/Alba.Testing/ScenarioContext.cs
+++ b/src/Alba.Testing/ScenarioContext.cs
@@ -5,31 +5,30 @@
namespace Alba.Testing
{
- public class ScenarioContext : IDisposable
+ public class ScenarioContext : IAsyncLifetime
{
protected CrudeRouter router = new CrudeRouter();
- protected readonly IAlbaHost host;
+ protected IAlbaHost host = null!;
- public ScenarioContext()
+ public async ValueTask InitializeAsync()
{
- host = new AlbaHost(Host.CreateDefaultBuilder()
+ host = await AlbaHost.For(Host.CreateDefaultBuilder()
.ConfigureServices((s) => s.AddMvcCore())
.ConfigureWebHostDefaults(c =>
c.Configure(app =>
{
app.Run(router.Invoke);
- })));
+ })));
}
-
protected Task fails(Action configuration)
{
return Exception.ShouldBeThrownBy(() => host.Scenario(configuration));
}
- public void Dispose()
+ public async ValueTask DisposeAsync()
{
- host?.Dispose();
+ if (host != null) await host.DisposeAsync();
}
}
}
\ No newline at end of file
diff --git a/src/Alba.Testing/ScenarioTests.cs b/src/Alba.Testing/ScenarioTests.cs
index 1b1559c..131b948 100644
--- a/src/Alba.Testing/ScenarioTests.cs
+++ b/src/Alba.Testing/ScenarioTests.cs
@@ -1,4 +1,5 @@
-using System.Text.Json;
+using Microsoft.AspNetCore.Http;
+using System.Text.Json;
using Alba.Internal;
using Shouldly;
@@ -13,8 +14,7 @@ public Task invoke_a_simple_string_endpoint()
{
router.Handlers["/memory/hello"] = c =>
{
- c.Response.Write("hello from the in memory host");
- return Task.CompletedTask;
+ return c.Response.WriteAsync("hello from the in memory host");
};
return host.Scenario(_ =>
@@ -29,8 +29,7 @@ public Task using_scenario_with_string_text_and_relative_url()
{
router.Handlers["/memory/hello"] = c =>
{
- c.Response.Write("hello from the in memory host");
- return Task.CompletedTask;
+ return c.Response.WriteAsync("hello from the in memory host");
};
return host.Scenario(x =>
@@ -51,8 +50,7 @@ public Task using_scenario_with_byte_array_and_content_type()
{
c.Request.Body.ReadAllBytes().ShouldBe(bytes);
c.Request.ContentType.ShouldBe(contentType);
- c.Response.Write("hello from the in memory host");
- return Task.CompletedTask;
+ return c.Response.WriteAsync("hello from the in memory host");
};
return host.Scenario(x =>
@@ -78,8 +76,7 @@ public Task using_scenario_with_raw_json_and_content_type()
entity.Name.ShouldBe("John");
entity.Age.ShouldBe(30);
c.Request.ContentType.ShouldBe(contentType);
- c.Response.Write("hello from the in memory host");
- return Task.CompletedTask;
+ return c.Response.WriteAsync("hello from the in memory host");
};
return host.Scenario(x =>
diff --git a/src/Alba.Testing/Security/JwtSecurityStubTests.cs b/src/Alba.Testing/Security/JwtSecurityStubTests.cs
index 25f194a..1c8431d 100644
--- a/src/Alba.Testing/Security/JwtSecurityStubTests.cs
+++ b/src/Alba.Testing/Security/JwtSecurityStubTests.cs
@@ -10,20 +10,21 @@
namespace Alba.Testing.Security
{
- public class JwtSecurityStubTests : IDisposable
+ public class JwtSecurityStubTests : IAsyncLifetime
{
private readonly JwtSecurityStub theStub;
- private readonly IAlbaHost _host;
+ private IAlbaHost _host = null!;
public JwtSecurityStubTests()
{
theStub = new JwtSecurityStub()
.With("foo", "bar")
.With("team", "chiefs");
+ }
-
-
- _host = Host.CreateDefaultBuilder().StartAlba(theStub);
+ public async ValueTask InitializeAsync()
+ {
+ _host = await Host.CreateDefaultBuilder().StartAlbaAsync(theStub);
theStub.Options = new JwtBearerOptions
{
@@ -79,10 +80,10 @@ public void additive_claims_on_the_token()
[Fact]
- public void should_handle_non_hmac_signing_key()
+ public async Task should_handle_non_hmac_signing_key()
{
using var ecdsa = ECDsa.Create();
- var host = Host.CreateDefaultBuilder()
+ await using var host = await Host.CreateDefaultBuilder()
.ConfigureServices(services =>
{
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
@@ -101,16 +102,16 @@ public void should_handle_non_hmac_signing_key()
};
});
})
- .StartAlba(theStub);
+ .StartAlbaAsync(theStub);
Action act = () => theStub.BuildJwtString(Array.Empty());
-
+
act.ShouldNotThrow();
}
- public void Dispose()
+ public async ValueTask DisposeAsync()
{
- _host?.Dispose();
+ if (_host != null) await _host.DisposeAsync();
}
}
}
\ No newline at end of file
diff --git a/src/Alba.Testing/Security/sse_with_jwt.cs b/src/Alba.Testing/Security/sse_with_jwt.cs
new file mode 100644
index 0000000..f4a3311
--- /dev/null
+++ b/src/Alba.Testing/Security/sse_with_jwt.cs
@@ -0,0 +1,74 @@
+using System.Security.Claims;
+using Alba.Security;
+using Shouldly;
+
+namespace Alba.Testing.Security;
+
+public class sse_with_jwt : IAsyncLifetime
+{
+ private IAlbaHost theHost = null!;
+
+ public async ValueTask InitializeAsync()
+ {
+ var stub = new JwtSecurityStub().With("foo", "bar");
+ theHost = await AlbaHost.For(stub);
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ await theHost.DisposeAsync();
+ }
+
+ [Fact]
+ public async Task can_stream_from_a_secured_endpoint()
+ {
+ using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
+
+ await using var stream = await theHost.StreamServerSentEvents(
+ x => x.Get.Url("/sse/secured"), timeout.Token);
+
+ var events = new List();
+ await foreach (var item in stream.ReadEvents(timeout.Token))
+ {
+ events.Add(item.Data);
+ }
+
+ events.ShouldBe(new[] { "bar-1", "bar-2" });
+ }
+
+ [Fact]
+ public async Task per_scenario_claims_flow_through_the_stream()
+ {
+ var stub = new JwtSecurityStub();
+ await using var host = await AlbaHost.For(stub);
+
+ using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
+
+ await using var stream = await host.StreamServerSentEvents(x =>
+ {
+ x.WithClaim(new Claim("foo", "streamed"));
+ x.Get.Url("/sse/secured");
+ }, timeout.Token);
+
+ var events = new List();
+ await foreach (var item in stream.ReadEvents(timeout.Token))
+ {
+ events.Add(item.Data);
+ }
+
+ events.ShouldBe(new[] { "streamed-1", "streamed-2" });
+ }
+
+ [Fact]
+ public async Task missing_token_surfaces_as_a_status_code_failure()
+ {
+ var ex = await Should.ThrowAsync(
+ () => theHost.StreamServerSentEvents(x =>
+ {
+ x.RemoveRequestHeader("Authorization");
+ x.Get.Url("/sse/secured");
+ }));
+
+ ex.Message.ShouldContain("Expected a status code between 200 and 299, but was 401");
+ }
+}
diff --git a/src/Alba.Testing/Sse/buffered_sse_reading.cs b/src/Alba.Testing/Sse/buffered_sse_reading.cs
new file mode 100644
index 0000000..aaa5c54
--- /dev/null
+++ b/src/Alba.Testing/Sse/buffered_sse_reading.cs
@@ -0,0 +1,69 @@
+using Shouldly;
+
+namespace Alba.Testing.Sse;
+
+public class buffered_sse_reading : IAsyncLifetime
+{
+ private IAlbaHost _host = null!;
+
+ public async ValueTask InitializeAsync()
+ {
+ _host = await AlbaHost.For();
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ await _host.DisposeAsync();
+ }
+
+ [Fact]
+ public async Task reads_events_from_a_buffered_response()
+ {
+ var result = await _host.Scenario(x => x.Get.Url("/sse/finite"));
+
+ var events = result.ReadAsServerSentEvents();
+
+ events.Count.ShouldBe(3);
+ events[0].EventType.ShouldBe("count");
+ events[0].EventId.ShouldBe("1");
+ events[0].Data.ShouldBe("{\"number\":1}");
+ events[2].EventId.ShouldBe("3");
+ }
+
+ [Fact]
+ public async Task reads_typed_events_with_the_application_json_options()
+ {
+ var result = await _host.Scenario(x => x.Get.Url("/sse/finite"));
+
+ var events = result.ReadAsServerSentEvents();
+
+ // Number is a public field, so this only works when the application's
+ // IncludeFields option is applied
+ events.Select(e => e.Data.Number).ShouldBe(new[] { 1, 2, 3 });
+ }
+
+ [Fact]
+ public async Task reading_is_repeatable()
+ {
+ var result = await _host.Scenario(x => x.Get.Url("/sse/finite"));
+
+ result.ReadAsServerSentEvents().Count.ShouldBe(3);
+ result.ReadAsServerSentEvents().Count.ShouldBe(3);
+ }
+
+ [Fact]
+ public async Task body_without_sse_framing_yields_no_events()
+ {
+ var result = await _host.Scenario(x =>
+ {
+ x.Get.Url("/");
+ });
+
+ result.ReadAsServerSentEvents().Count.ShouldBe(0);
+ }
+}
+
+public class CounterEvent
+{
+ public int Number;
+}
diff --git a/src/Alba.Testing/Sse/streaming_sse.cs b/src/Alba.Testing/Sse/streaming_sse.cs
new file mode 100644
index 0000000..f769e00
--- /dev/null
+++ b/src/Alba.Testing/Sse/streaming_sse.cs
@@ -0,0 +1,190 @@
+using System.Net.ServerSentEvents;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+using MinimalApiWithOakton;
+using Shouldly;
+
+namespace Alba.Testing.Sse;
+
+public class streaming_sse : IAsyncLifetime
+{
+ private IAlbaHost _host = null!;
+
+ public async ValueTask InitializeAsync()
+ {
+ _host = await AlbaHost.For();
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ await _host.DisposeAsync();
+ }
+
+ private static CancellationTokenSource testTimeout() => new(TimeSpan.FromSeconds(10));
+
+ [Fact]
+ public async Task can_stream_a_finite_event_stream()
+ {
+ using var timeout = testTimeout();
+
+ await using var stream = await _host.StreamServerSentEvents(x => x.Get.Url("/sse/finite"), timeout.Token);
+
+ var events = new List>();
+ await foreach (var item in stream.ReadEvents(timeout.Token))
+ {
+ events.Add(item);
+ }
+
+ events.Count.ShouldBe(3);
+ events[0].EventType.ShouldBe("count");
+ events[0].EventId.ShouldBe("1");
+ events[2].Data.ShouldBe("{\"number\":3}");
+ }
+
+ [Fact]
+ public async Task can_stream_typed_events()
+ {
+ using var timeout = testTimeout();
+
+ await using var stream = await _host.StreamServerSentEvents(x => x.Get.Url("/sse/finite"), timeout.Token);
+
+ var numbers = new List();
+ await foreach (var item in stream.ReadEvents(timeout.Token))
+ {
+ numbers.Add(item.Data.Number);
+ }
+
+ numbers.ShouldBe(new[] { 1, 2, 3 });
+ }
+
+ [Fact]
+ public async Task disposing_the_stream_aborts_the_request_on_the_server()
+ {
+ using var timeout = testTimeout();
+
+ var tracker = _host.Services.GetRequiredService();
+ tracker.Reset();
+
+ var stream = await _host.StreamServerSentEvents(x => x.Get.Url("/sse/infinite"), timeout.Token);
+
+ var count = 0;
+ await foreach (var _ in stream.ReadEvents(timeout.Token))
+ {
+ if (++count == 2) break;
+ }
+
+ await stream.DisposeAsync();
+
+ await tracker.Aborted.WaitAsync(timeout.Token);
+ }
+
+ [Fact]
+ public async Task before_each_async_actions_apply_to_streams()
+ {
+ await using var host = await AlbaHost.For();
+ host.BeforeEachAsync(s =>
+ {
+ s.WithRequestHeader("x-sse-echo", "from-async-hook");
+ return Task.CompletedTask;
+ });
+
+ using var timeout = testTimeout();
+ await using var stream = await host.StreamServerSentEvents(x => x.Get.Url("/sse/echo-header"), timeout.Token);
+
+ var events = new List();
+ await foreach (var item in stream.ReadEvents(timeout.Token))
+ {
+ events.Add(item.Data);
+ }
+
+ events.ShouldBe(new[] { "from-async-hook" });
+ }
+
+ [Fact]
+ public async Task synchronous_before_each_actions_apply_to_streams()
+ {
+ await using var host = await AlbaHost.For();
+ host.BeforeEach(c => c.Request.Headers["x-sse-echo"] = "from-sync-hook");
+
+ using var timeout = testTimeout();
+ await using var stream = await host.StreamServerSentEvents(x => x.Get.Url("/sse/echo-header"), timeout.Token);
+
+ var events = new List();
+ await foreach (var item in stream.ReadEvents(timeout.Token))
+ {
+ events.Add(item.Data);
+ }
+
+ events.ShouldBe(new[] { "from-sync-hook" });
+ }
+
+ [Fact]
+ public async Task after_each_actions_run_when_the_stream_is_disposed()
+ {
+ await using var host = await AlbaHost.For();
+
+ var called = false;
+ HttpContext observed = new DefaultHttpContext();
+ host.AfterEachAsync(c =>
+ {
+ called = true;
+ observed = c;
+ return Task.CompletedTask;
+ });
+
+ using var timeout = testTimeout();
+ var stream = await host.StreamServerSentEvents(x => x.Get.Url("/sse/finite"), timeout.Token);
+
+ called.ShouldBeFalse();
+
+ await stream.DisposeAsync();
+
+ called.ShouldBeTrue();
+ observed.ShouldBeNull();
+ }
+
+ [Fact]
+ public async Task non_sse_endpoints_are_rejected()
+ {
+ var ex = await Should.ThrowAsync(
+ () => _host.StreamServerSentEvents(x => x.Get.Url("/")));
+
+ ex.Message.ShouldContain("text/event-stream");
+ }
+
+ [Fact]
+ public async Task response_assertions_are_rejected()
+ {
+ var ex = await Should.ThrowAsync(
+ () => _host.StreamServerSentEvents(x =>
+ {
+ x.Get.Url("/sse/finite");
+ x.ContentShouldContain("number");
+ }));
+
+ ex.Message.ShouldContain("Response assertions are not supported");
+ }
+
+ [Fact]
+ public async Task missing_url_is_rejected()
+ {
+ var ex = await Should.ThrowAsync(
+ () => _host.StreamServerSentEvents(_ => { }));
+
+ ex.Message.ShouldBe("This scenario has no defined url");
+ }
+
+ [Fact]
+ public async Task the_event_stream_can_only_be_read_once()
+ {
+ using var timeout = testTimeout();
+
+ await using var stream = await _host.StreamServerSentEvents(x => x.Get.Url("/sse/finite"), timeout.Token);
+
+ await foreach (var _ in stream.ReadEvents(timeout.Token))
+ {
+ }
+
+ Should.Throw(() => stream.ReadEvents());
+ }
+}
diff --git a/src/Alba.Testing/before_and_after_actions.cs b/src/Alba.Testing/before_and_after_actions.cs
index bb281b6..bccb107 100644
--- a/src/Alba.Testing/before_and_after_actions.cs
+++ b/src/Alba.Testing/before_and_after_actions.cs
@@ -43,8 +43,9 @@ internal void sample_usage(AlbaHost system)
});
- // Asynchronously
- system.BeforeEachAsync(context =>
+ // Asynchronously, before the HTTP request executes. Modify the
+ // outgoing request itself through scenario.ConfigureHttpContext()
+ system.BeforeEachAsync(scenario =>
{
// do something asynchronous here
return Task.CompletedTask;
@@ -63,7 +64,7 @@ internal void sample_usage(AlbaHost system)
[Fact]
public async Task synchronous_before_and_after()
{
- using (var system = new AlbaHost(EmptyHostBuilder()))
+ await using (var system = await EmptyHostBuilder().StartAlbaAsync())
{
// Quick check
system.Services.ShouldNotBeNull();
@@ -159,11 +160,11 @@ public async Task authentication_with_before()
var authenticatedUser = new ClaimsPrincipal(new ClaimsIdentity(null, "Basic"));
//This works
- await using var system = new AlbaHost(AuthenticatedHostBuilder())
- .BeforeEachAsync(c => Task.Run(() =>
+ await using var system = (await AuthenticatedHostBuilder().StartAlbaAsync())
+ .BeforeEachAsync(s => Task.Run(() =>
{
_output.WriteLine("In BeforeEach");
- c.User = authenticatedUser;
+ s.ConfigureHttpContext(c => c.User = authenticatedUser);
}));
var result = await system.Scenario(x => x.Get.Url("/"));
result.Context.Response.StatusCode.ShouldBe(200);
@@ -175,7 +176,7 @@ public async Task authentication_with_long_running_before()
//This doesn't
- using (var system = new AlbaHost(AuthenticatedHostBuilder())
+ await using (var system = (await AuthenticatedHostBuilder().StartAlbaAsync())
.BeforeEachAsync(doSecurity))
{
var result = await system.Scenario(x => x.Get.Url("/"));
@@ -183,14 +184,64 @@ public async Task authentication_with_long_running_before()
}
}
- private async Task doSecurity(HttpContext context)
+ [Fact]
+ public async Task async_prepare_can_modify_the_outgoing_request()
+ {
+ var builder = new HostBuilder().ConfigureWebHost(x =>
+ {
+ x.Configure(app =>
+ app.Run(c => c.Response.WriteAsync(c.Request.Headers["x-prepared"].ToString())));
+ });
+
+ await using var system = await builder.StartAlbaAsync();
+
+ system.BeforeEachAsync(async scenario =>
+ {
+ await Task.Delay(50);
+ scenario.WithRequestHeader("x-prepared", "from-prepare");
+ });
+
+ var result = await system.Scenario(x => x.Get.Url("/"));
+
+ (await result.ReadAsTextAsync()).ShouldBe("from-prepare");
+ }
+
+ [Fact]
+ public async Task async_prepares_run_before_synchronous_before_actions()
+ {
+ await using var system = await EmptyHostBuilder().StartAlbaAsync();
+
+ var order = new List();
+
+ system.BeforeEach(c => order.Add("sync"));
+ system.BeforeEachAsync(s =>
+ {
+ order.Add("async");
+ return Task.CompletedTask;
+ });
+
+ await system.Scenario(x => x.Get.Url("/"));
+
+ order.ShouldBe(new[] { "async", "sync" });
+ }
+
+ [Fact]
+ public async Task failing_async_prepare_fails_the_scenario()
+ {
+ await using var system = await EmptyHostBuilder().StartAlbaAsync();
+
+ system.BeforeEachAsync(_ => throw new DivideByZeroException());
+
+ await Should.ThrowAsync(() => system.Scenario(x => x.Get.Url("/")));
+ }
+
+ private async Task doSecurity(Scenario scenario)
{
var authenticatedUser = new ClaimsPrincipal(new ClaimsIdentity(null, "Basic"));
-
+
_output.WriteLine("Start BeforeEach");
await Task.Delay(100);
- //Thread.Sleep(100);
- context.User = authenticatedUser;
+ scenario.ConfigureHttpContext(c => c.User = authenticatedUser);
_output.WriteLine("End BeforeEach");
}
}
diff --git a/src/Alba.Testing/reading_and_writing_xml_to_context.cs b/src/Alba.Testing/reading_and_writing_xml_to_context.cs
index 3665f1e..aac9fd8 100644
--- a/src/Alba.Testing/reading_and_writing_xml_to_context.cs
+++ b/src/Alba.Testing/reading_and_writing_xml_to_context.cs
@@ -4,6 +4,7 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Hosting;
using Shouldly;
namespace Alba.Testing
@@ -46,13 +47,16 @@ public void can_get_raw_xml_document()
}
[Fact]
- public void can_write_xml_to_request()
+ public async Task can_write_xml_to_request()
{
var context = new DefaultHttpContext();
- using var system = AlbaHost.For(b =>
- b.Configure(app => app.Run(c => c.Response.WriteAsync("Hello"))));
-
- var scenario = new Scenario(system);
+ var builder = new HostBuilder().ConfigureWebHost(x =>
+ {
+ x.Configure(app => app.Run(c => c.Response.WriteAsync("Hello")));
+ });
+ await using var system = await AlbaHost.For(builder);
+
+ var scenario = new Scenario((AlbaHost)system);
new HttpRequestBody(scenario).XmlInputIs(new MyMessage { Age = 3, Name = "Declan" });
scenario.SetupHttpContext(context);
diff --git a/src/Alba.Testing/time_provider_override_usage.cs b/src/Alba.Testing/time_provider_override_usage.cs
new file mode 100644
index 0000000..44d1c18
--- /dev/null
+++ b/src/Alba.Testing/time_provider_override_usage.cs
@@ -0,0 +1,101 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using MinimalApiWithOakton;
+using Shouldly;
+
+namespace Alba.Testing
+{
+ public class time_provider_override_usage
+ {
+ private static readonly DateTimeOffset TheStart = new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero);
+
+ #region sample_time_provider_override
+ [Fact]
+ public async Task freezing_and_advancing_the_clock()
+ {
+ // The clock is both the extension and the controllable TimeProvider
+ var clock = new TimeProviderOverride(new DateTimeOffset(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
+
+ await using var host = await AlbaHost.For(clock);
+
+ // Time is frozen at the configured start instant
+ (await host.GetAsJson("/time"))!.UtcNow
+ .ShouldBe(new DateTimeOffset(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
+ (await host.GetAsJson("/time"))!.UtcNow
+ .ShouldBe(new DateTimeOffset(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
+
+ // Travel forward three days
+ clock.Advance(TimeSpan.FromDays(3));
+ (await host.GetAsJson("/time"))!.UtcNow
+ .ShouldBe(new DateTimeOffset(2030, 1, 4, 0, 0, 0, TimeSpan.Zero));
+
+ // Or pin the clock to any instant
+ clock.SetUtcNow(new DateTimeOffset(2031, 6, 15, 12, 0, 0, TimeSpan.Zero));
+ (await host.GetAsJson("/time"))!.UtcNow
+ .ShouldBe(new DateTimeOffset(2031, 6, 15, 12, 0, 0, TimeSpan.Zero));
+ }
+ #endregion
+
+ [Fact]
+ public async Task the_clock_is_the_only_time_provider_registration()
+ {
+ var clock = new TimeProviderOverride(TheStart);
+
+ // MinimalApiWithOakton registers TimeProvider.System itself, and the
+ // framework TryAdds one as well; the override must be the single winner
+ await using var host = await AlbaHost.For(clock);
+
+ host.Services.GetRequiredService().ShouldBeSameAs(clock);
+ host.Services.GetServices().Single().ShouldBeSameAs(clock);
+ }
+
+ [Fact]
+ public async Task with_host_builder()
+ {
+ var clock = new TimeProviderOverride(TheStart);
+
+ var builder = new HostBuilder().ConfigureWebHost(x =>
+ {
+ x.ConfigureServices(s => s.AddSingleton(TimeProvider.System));
+ x.Configure(app => app.Run(c => c.Response.WriteAsync("ok")));
+ });
+
+ await using var host = await AlbaHost.For(builder, clock);
+
+ assertClockWasApplied(host, clock);
+ }
+
+ [Fact]
+ public async Task with_web_application_builder()
+ {
+ var clock = new TimeProviderOverride(TheStart);
+
+ var builder = WebApplication.CreateBuilder();
+ builder.Services.AddSingleton(TimeProvider.System);
+
+ await using var host = await builder.StartAlbaAsync(app => { }, clock);
+
+ assertClockWasApplied(host, clock);
+ }
+
+ [Fact]
+ public async Task with_web_application_factory()
+ {
+ var clock = new TimeProviderOverride(TheStart);
+
+ await using var host = await AlbaHost.For(clock);
+
+ assertClockWasApplied(host, clock);
+ }
+
+ private static void assertClockWasApplied(IAlbaHost host, TimeProviderOverride clock)
+ {
+ var provider = host.Services.GetRequiredService();
+ provider.ShouldBeSameAs(clock);
+ provider.GetUtcNow().ShouldBe(TheStart);
+ }
+ }
+}
diff --git a/src/Alba.Testing/using_extensions_with_sync_builder.cs b/src/Alba.Testing/using_extensions_with_sync_builder.cs
index c247e1b..93002cd 100644
--- a/src/Alba.Testing/using_extensions_with_sync_builder.cs
+++ b/src/Alba.Testing/using_extensions_with_sync_builder.cs
@@ -1,9 +1,15 @@
+#nullable enable
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Shouldly;
namespace Alba.Testing
{
- public class using_extensions_with_sync_builder
+ public class using_extensions_with_async_builder
{
private readonly FakeExtension extension1;
private readonly FakeExtension extension2;
@@ -11,7 +17,7 @@ public class using_extensions_with_sync_builder
private readonly IHostBuilder theBuilder;
private readonly IAlbaHost theHost;
- public using_extensions_with_sync_builder()
+ public using_extensions_with_async_builder()
{
extension1 = new FakeExtension();
extension2 = new FakeExtension();
@@ -19,7 +25,7 @@ public using_extensions_with_sync_builder()
theBuilder = Host.CreateDefaultBuilder();
- theHost = theBuilder.StartAlba(extension1, extension2, extension3);
+ theHost = theBuilder.StartAlbaAsync(extension1, extension2, extension3).GetAwaiter().GetResult();
}
[Fact]
@@ -53,74 +59,14 @@ public async Task all_extensions_disposed_async()
}
[Fact]
- public void all_extensions_disposed()
- {
- theHost.Dispose();
-
- extension1.WasDisposed.ShouldBeTrue();
- extension2.WasDisposed.ShouldBeTrue();
- extension3.WasDisposed.ShouldBeTrue();
- }
- }
-
- public class using_extensions_with_async_builder
- {
- private readonly FakeExtension extension1;
- private readonly FakeExtension extension2;
- private readonly FakeExtension extension3;
- private readonly IHostBuilder theBuilder;
- private readonly IAlbaHost theHost;
-
- public using_extensions_with_async_builder()
- {
- extension1 = new FakeExtension();
- extension2 = new FakeExtension();
- extension3 = new FakeExtension();
-
- theBuilder = Host.CreateDefaultBuilder();
-
- theHost = theBuilder.StartAlbaAsync(extension1, extension2, extension3).GetAwaiter().GetResult();
- }
-
- [Fact]
- public void all_extensions_should_be_applied_to_configure()
- {
- extension1.WasConfigured.ShouldBeTrue();
- extension2.WasConfigured.ShouldBeTrue();
- extension3.WasConfigured.ShouldBeTrue();
-
- theHost.Dispose();
- }
-
- [Fact]
- public void all_extensions_should_be_started()
+ public void sync_dispose_disposes_extensions_asynchronously()
{
- extension1.WasStarted.ShouldBeTrue();
- extension2.WasStarted.ShouldBeTrue();
- extension3.WasStarted.ShouldBeTrue();
-
theHost.Dispose();
- }
- [Fact]
- public async Task all_extensions_disposed_async()
- {
- await theHost.DisposeAsync();
-
extension1.WasAsyncDisposed.ShouldBeTrue();
extension2.WasAsyncDisposed.ShouldBeTrue();
extension3.WasAsyncDisposed.ShouldBeTrue();
}
-
- [Fact]
- public void all_extensions_disposed()
- {
- theHost.Dispose();
-
- extension1.WasDisposed.ShouldBeTrue();
- extension2.WasDisposed.ShouldBeTrue();
- extension3.WasDisposed.ShouldBeTrue();
- }
}
@@ -149,12 +95,92 @@ public Task Start(IAlbaHost host)
public bool WasStarted { get; set; }
- public IHostBuilder Configure(IHostBuilder builder)
+ public void Configure(IAlbaHostBuilder builder)
{
WasConfigured = true;
- return builder;
}
public bool WasConfigured { get; set; }
}
+
+ public class extensions_behave_identically_across_bootstrapping_styles
+ {
+ [Fact]
+ public async Task with_host_builder()
+ {
+ var builder = new HostBuilder().ConfigureWebHost(x =>
+ {
+ x.Configure(app => app.Run(c => c.Response.WriteAsync("ok")));
+ });
+
+ await using var host = await AlbaHost.For(builder, new ServiceAndConfigExtension());
+
+ assertExtensionWasApplied(host);
+ }
+
+ [Fact]
+ public async Task with_web_application_builder()
+ {
+ await using var host = await WebApplication.CreateBuilder()
+ .StartAlbaAsync(app => { }, new ServiceAndConfigExtension());
+
+ assertExtensionWasApplied(host);
+ }
+
+ [Fact]
+ public async Task with_web_application_factory()
+ {
+ await using var host = await AlbaHost.For(new ServiceAndConfigExtension());
+
+ assertExtensionWasApplied(host);
+ }
+
+ [Fact]
+ public async Task configuration_override_beats_existing_appsettings_value_on_minimal_hosting_under_web_application_factory()
+ {
+ // MinimalApiWithOakton's appsettings.json sets Logging:LogLevel:Default to "Information";
+ // this is the dotnet/aspnetcore#37680 scenario the ConfigurationOverride extension exists for
+ var overrides = ConfigurationOverride.Create(new Dictionary
+ {
+ ["Logging:LogLevel:Default"] = "Critical"
+ });
+
+ await using var host = await AlbaHost.For(overrides);
+
+ var configuration = host.Services.GetRequiredService();
+ configuration["Logging:LogLevel:Default"].ShouldBe("Critical");
+ }
+
+ private static void assertExtensionWasApplied(IAlbaHost host)
+ {
+ host.Services.GetRequiredService().ShouldNotBeNull();
+
+ var configuration = host.Services.GetRequiredService();
+ configuration["Alba:ExtensionValue"].ShouldBe("from-extension");
+ }
+
+ private class ServiceAndConfigExtension : IAlbaExtension
+ {
+ public void Dispose()
+ {
+ }
+
+ public ValueTask DisposeAsync() => ValueTask.CompletedTask;
+
+ public Task Start(IAlbaHost host) => Task.CompletedTask;
+
+ public void Configure(IAlbaHostBuilder builder)
+ {
+ builder.ConfigureServices(s => s.AddSingleton(new MarkerService()));
+ builder.ConfigureConfiguration(c => c.AddInMemoryCollection(new Dictionary
+ {
+ ["Alba:ExtensionValue"] = "from-extension"
+ }));
+ }
+ }
+
+ private class MarkerService
+ {
+ }
+ }
}
\ No newline at end of file
diff --git a/src/Alba/Alba.csproj b/src/Alba/Alba.csproj
index a26fb15..1687141 100644
--- a/src/Alba/Alba.csproj
+++ b/src/Alba/Alba.csproj
@@ -2,8 +2,8 @@
Supercharged integration testing for ASP.NET Core HTTP endpoints
- 8.5.3
- net8.0;net9.0;net10.0
+ 9.0.0-beta.1
+ net10.0
http://jasperfx.github.io/alba/
Apache-2.0
git
@@ -24,23 +24,11 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
diff --git a/src/Alba/AlbaHost.cs b/src/Alba/AlbaHost.cs
index 1846a45..b136679 100644
--- a/src/Alba/AlbaHost.cs
+++ b/src/Alba/AlbaHost.cs
@@ -1,5 +1,6 @@
using System.Diagnostics;
using System.Runtime.ExceptionServices;
+using System.Text.Json;
using Alba.Internal;
using Alba.Serialization;
using Microsoft.AspNetCore.Builder;
@@ -25,68 +26,41 @@ public class AlbaHost : IAlbaHost
private readonly List> _afterEach = new();
-
- private readonly List> _beforeEach = new();
+ private readonly List> _beforeEachAsync = new();
+ private readonly List> _beforeEachSync = new();
private AlbaHost(IHost host, params IAlbaExtension[] extensions)
{
_host = host;
Server = host.GetTestServer();
- Server.AllowSynchronousIO = true;
-
Extensions = extensions;
- var jsonInput = findInputFormatter("application/json");
- var jsonOutput = findOutputFormatter("application/json");
-
- if (jsonInput != null && jsonOutput != null)
- {
- MvcStrategy = new FormatterSerializer(this, jsonInput, jsonOutput);
- }
-
- MinimalApiStrategy = new SystemTextJsonSerializer(this);
-
- DefaultJson = MvcStrategy ?? MinimalApiStrategy;
+ (MvcStrategy, MinimalApiStrategy, DefaultJson) = buildJsonStrategies();
}
- public AlbaHost(IHostBuilder builder, params IAlbaExtension[] extensions)
+ private (IJsonStrategy? Mvc, IJsonStrategy MinimalApi, IJsonStrategy Default) buildJsonStrategies()
{
- builder = builder
- .ConfigureServices(_ =>
- {
- _.AddHttpContextAccessor();
- _.AddSingleton();
- });
-
- foreach (var extension in extensions) builder = extension.Configure(builder);
-
- _host = builder.Start();
-
- Server = _host.GetTestServer();
- Server.AllowSynchronousIO = true;
-
- Extensions = extensions;
-
- foreach (var extension in extensions) extension.Start(this).GetAwaiter().GetResult();
-
var jsonInput = findInputFormatter("application/json");
var jsonOutput = findOutputFormatter("application/json");
+ IJsonStrategy? mvc = null;
if (jsonInput != null && jsonOutput != null)
{
- MvcStrategy = new FormatterSerializer(this, jsonInput, jsonOutput);
+ mvc = new FormatterSerializer(this, jsonInput, jsonOutput);
}
- MinimalApiStrategy = new SystemTextJsonSerializer(this);
+ var minimalApi = new SystemTextJsonSerializer(this);
- DefaultJson = MvcStrategy ?? MinimalApiStrategy;
+ return (mvc, minimalApi, mvc ?? minimalApi);
}
internal IJsonStrategy? MvcStrategy { get; }
internal IJsonStrategy MinimalApiStrategy { get; }
internal IJsonStrategy DefaultJson { get; }
+ internal JsonSerializerOptions StjJsonOptions => ((SystemTextJsonSerializer)MinimalApiStrategy).Options;
+
public IReadOnlyList Extensions { get; }
///
@@ -116,20 +90,14 @@ public AlbaHost(IHostBuilder builder, params IAlbaExtension[] extensions)
public void Dispose()
{
- foreach (var extension in Extensions) extension.Dispose();
- Server.Dispose();
- _host?.StopAsync();
- _host?.Dispose();
- _factory?.Dispose();
+ // Single teardown implementation lives in DisposeAsync; blocking once
+ // at teardown is the only intentional block left in Alba
+ DisposeAsync().AsTask().GetAwaiter().GetResult();
}
public IAlbaHost BeforeEach(Action beforeEach)
{
- _beforeEach.Add(c =>
- {
- beforeEach(c);
- return Task.CompletedTask;
- });
+ _beforeEachSync.Add(beforeEach);
return this;
}
@@ -145,9 +113,9 @@ public IAlbaHost AfterEach(Action afterEach)
return this;
}
- public IAlbaHost BeforeEachAsync(Func beforeEach)
+ public IAlbaHost BeforeEachAsync(Func beforeEach)
{
- _beforeEach.Add(beforeEach);
+ _beforeEachAsync.Add(beforeEach);
return this;
}
@@ -178,6 +146,10 @@ public async Task Scenario(
configure(scenario);
+ foreach (var prepare in _beforeEachAsync) await prepare(scenario);
+
+ foreach (var preparation in scenario.AsyncPreparations) await preparation();
+
scenario.Rewind();
HttpContext? context = null;
@@ -199,8 +171,7 @@ public async Task Scenario(
foreach (var pair in scenario.Items) c.Items.Add(pair.Key, pair.Value);
- // No async available here :(
- foreach (var func in _beforeEach) func(c).GetAwaiter().GetResult();
+ foreach (var apply in _beforeEachSync) apply(c);
c.Request.Body.Position = 0;
@@ -223,20 +194,141 @@ public async Task Scenario(
}
scenario.RunAssertions(context);
+
+ if (context.Response.Body.CanSeek)
+ {
+ context.Response.Body.Position = 0;
+ }
+
+ return new ScenarioResult(this, context);
}
finally
{
foreach (var func in _afterEach) await func(context);
}
+ }
+
+
+ public async Task StreamServerSentEvents(Action configure,
+ CancellationToken cancellationToken = default)
+ {
+ var scenario = new Scenario(this);
+
+ configure(scenario);
+
+ if (scenario.HasResponseAssertions)
+ {
+ throw new InvalidOperationException(
+ "Response assertions are not supported with StreamServerSentEvents(). Assert on the streamed events instead.");
+ }
+
+ foreach (var prepare in _beforeEachAsync) await prepare(scenario);
+
+ foreach (var preparation in scenario.AsyncPreparations) await preparation();
+
+ scenario.Rewind();
+
+ Activity? activity = null;
+ var handler = Server.CreateHandler(c =>
+ {
+ try
+ {
+ if (scenario.Claims.Any())
+ {
+ c.Items.Add("alba_claims", scenario.Claims.ToArray());
+ }
+
+ if (scenario.RemovedClaims.Any())
+ {
+ c.Items.Add("alba_removed_claims", scenario.RemovedClaims.ToArray());
+ }
+
+ foreach (var pair in scenario.Items) c.Items.Add(pair.Key, pair.Value);
+
+ foreach (var apply in _beforeEachSync) apply(c);
+
+ // The placeholder request message maps to "/"; clear the path so
+ // the missing-url check below still applies
+ c.Request.Path = PathString.Empty;
+
+ scenario.SetupHttpContext(c);
+
+ if (c.Request.Path == null)
+ {
+ throw new InvalidOperationException("This scenario has no defined url");
+ }
+
+ activity = AlbaTracing.StartRequestActivity(c.Request);
+ }
+ catch (Exception e)
+ {
+ scenario.Exception = e;
+ }
+ });
+
+ var invoker = new HttpMessageInvoker(handler);
+ var request = new HttpRequestMessage(HttpMethod.Get, new Uri(Server.BaseAddress, "/"));
+
+ HttpResponseMessage response;
+ try
+ {
+ // HttpMessageInvoker does not buffer response content, so this returns
+ // as soon as the application flushes its response headers
+ response = await invoker.SendAsync(request, cancellationToken);
+ }
+ catch
+ {
+ invoker.Dispose();
+ activity?.Dispose();
+ throw;
+ }
- if (context.Response.Body.CanSeek)
+ if (scenario.Exception != null)
{
- context.Response.Body.Position = 0;
+ await cleanupFailedStream(response, invoker, activity);
+ ExceptionDispatchInfo.Throw(scenario.Exception);
}
- return new ScenarioResult(this, context);
+ var statusCode = (int)response.StatusCode;
+ var statusFailure = !scenario.StatusCodeIgnored && (scenario.ExpectedStatusCode.HasValue
+ ? statusCode != scenario.ExpectedStatusCode.Value
+ : statusCode < 200 || statusCode >= 300);
+ if (statusFailure)
+ {
+ var body = await response.Content.ReadAsStringAsync(cancellationToken);
+ await cleanupFailedStream(response, invoker, activity);
+
+ var ex = new ScenarioAssertionException();
+ ex.Add(scenario.ExpectedStatusCode.HasValue
+ ? $"Expected status code {scenario.ExpectedStatusCode}, but was {statusCode}"
+ : $"Expected a status code between 200 and 299, but was {statusCode}");
+ ex.AddBody(body);
+ throw ex;
+ }
+
+ var contentType = response.Content.Headers.ContentType?.MediaType;
+ if (contentType != MimeType.EventStream.Value)
+ {
+ await cleanupFailedStream(response, invoker, activity);
+ throw new InvalidOperationException(
+ $"The response content type is '{contentType ?? "unknown"}', not '{MimeType.EventStream.Value}'");
+ }
+
+ activity?.SetTag(AlbaTracing.HttpStatusCode, (int)response.StatusCode);
+
+ var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
+ return new SseStreamResult(this, response, stream, invoker, activity, _afterEach);
}
+ private async Task cleanupFailedStream(HttpResponseMessage response, HttpMessageInvoker invoker,
+ Activity? activity)
+ {
+ response.Dispose();
+ invoker.Dispose();
+ activity?.Dispose();
+
+ foreach (var func in _afterEach) await func(null);
+ }
public async ValueTask DisposeAsync()
{
@@ -265,7 +357,8 @@ public static async Task For(IHostBuilder builder, params IAlbaExtens
_.AddSingleton();
});
- foreach (var extension in extensions) builder = extension.Configure(builder);
+ var adapter = new HostBuilderAdapter(builder);
+ foreach (var extension in extensions) extension.Configure(adapter);
var host = await builder.StartAsync();
@@ -290,9 +383,10 @@ public static async Task For(WebApplicationBuilder builder, Action>();
return options.Get("").OutputFormatters.OfType()
- .FirstOrDefault(x => x.SupportedMediaTypes.Contains(contentType));
+ .Where(x => x.SupportedMediaTypes.Contains(contentType))
+ .OrderBy(rankJsonFormatter)
+ .FirstOrDefault();
}
private InputFormatter? findInputFormatter(string contentType)
{
var options = Services.GetRequiredService>();
return options.Get("").InputFormatters.OfType()
- .FirstOrDefault(x => x.SupportedMediaTypes.Contains(contentType));
+ .Where(x => x.SupportedMediaTypes.Contains(contentType))
+ .OrderBy(rankJsonFormatter)
+ .FirstOrDefault();
+ }
+
+ // Third-party formatters such as OData's register ahead of the framework's JSON
+ // formatters and advertise "application/json", but cannot run outside their own
+ // pipeline (GH-116). Prefer the formatter the application actually uses for plain JSON.
+ private static int rankJsonFormatter(object formatter)
+ {
+ if (formatter is SystemTextJsonInputFormatter or SystemTextJsonOutputFormatter) return 0;
+
+ // Alba doesn't reference Microsoft.AspNetCore.Mvc.NewtonsoftJson, so match by name;
+ // walk base types so subclasses rank the same
+ for (var type = formatter.GetType(); type != null; type = type.BaseType)
+ {
+ if (type.FullName is "Microsoft.AspNetCore.Mvc.Formatters.NewtonsoftJsonInputFormatter"
+ or "Microsoft.AspNetCore.Mvc.Formatters.NewtonsoftJsonOutputFormatter")
+ {
+ return 1;
+ }
+ }
+
+ return 2;
}
public async Task Invoke(Action setup)
@@ -394,6 +501,17 @@ public async Task Invoke(Action setup)
activity = AlbaTracing.StartRequestActivity(c.Request);
});
activity?.SetResponseTags(context.Response);
+
+ // Buffer the response so all subsequent reads are seekable,
+ // memory-only operations
+ if (!context.Response.Body.CanSeek)
+ {
+ var buffered = new MemoryStream();
+ await context.Response.Body.CopyToAsync(buffered);
+ buffered.Position = 0;
+ context.Response.Body = buffered;
+ }
+
return context;
}
finally
@@ -401,22 +519,4 @@ public async Task Invoke(Action setup)
activity?.Dispose();
}
}
-
-
- ///
- /// Creates a SystemUnderTest from a default HostBuilder using the provided IWebHostBuilder
- ///
- ///
- /// Optional configuration of the IWebHostBuilder to be applied *after* the call to
- /// UseStartup()
- ///
- /// The system under test
- public static AlbaHost For(Action configuration)
- {
- var builder = Host.CreateDefaultBuilder();
-
- builder.ConfigureWebHostDefaults(configuration);
-
- return new AlbaHost(builder);
- }
}
\ No newline at end of file
diff --git a/src/Alba/AlbaHostBuilderAdapters.cs b/src/Alba/AlbaHostBuilderAdapters.cs
new file mode 100644
index 0000000..50fbc4a
--- /dev/null
+++ b/src/Alba/AlbaHostBuilderAdapters.cs
@@ -0,0 +1,57 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+
+namespace Alba;
+
+///
+/// Applies extension configuration to IHostBuilder-based bootstrapping,
+/// including the host builder supplied by WebApplicationFactory
+///
+internal sealed class HostBuilderAdapter : IAlbaHostBuilder
+{
+ private readonly IHostBuilder _builder;
+
+ public HostBuilderAdapter(IHostBuilder builder)
+ {
+ _builder = builder;
+ }
+
+ public void ConfigureServices(Action configure)
+ {
+ _builder.ConfigureServices((_, services) => configure(services));
+ }
+
+ public void ConfigureConfiguration(Action configure)
+ {
+ // Host configuration is what reaches applications created through
+ // WebApplicationFactory; see dotnet/aspnetcore#37680
+ _builder.ConfigureHostConfiguration(configure);
+ }
+}
+
+///
+/// Applies extension configuration directly to a WebApplicationBuilder
+///
+internal sealed class WebApplicationBuilderAdapter : IAlbaHostBuilder
+{
+ private readonly WebApplicationBuilder _builder;
+
+ public WebApplicationBuilderAdapter(WebApplicationBuilder builder)
+ {
+ _builder = builder;
+ }
+
+ public void ConfigureServices(Action configure)
+ {
+ configure(_builder.Services);
+ }
+
+ public void ConfigureConfiguration(Action configure)
+ {
+ // Sources added to the ConfigurationManager here land after the
+ // application's own sources and therefore take precedence
+ configure(_builder.Configuration);
+ }
+}
diff --git a/src/Alba/AlbaHostExtensions.cs b/src/Alba/AlbaHostExtensions.cs
index 42c798d..c1777d9 100644
--- a/src/Alba/AlbaHostExtensions.cs
+++ b/src/Alba/AlbaHostExtensions.cs
@@ -34,11 +34,6 @@ public static Task StartAlbaAsync(this IHostBuilder builder, params I
return AlbaHost.For(builder, extensions);
}
- public static IAlbaHost StartAlba(this IHostBuilder builder, params IAlbaExtension[] extensions)
- {
- return new AlbaHost(builder, extensions);
- }
-
///
/// Shortcut to issue a POST with a Json serialized request body and a Json serialized
/// response body
diff --git a/src/Alba/AlbaWebApplicationFactory.cs b/src/Alba/AlbaWebApplicationFactory.cs
index 731d3f3..964ded3 100644
--- a/src/Alba/AlbaWebApplicationFactory.cs
+++ b/src/Alba/AlbaWebApplicationFactory.cs
@@ -33,9 +33,10 @@ protected override void ConfigureWebHost(IWebHostBuilder builder)
protected override IHost CreateHost(IHostBuilder builder)
{
+ var adapter = new HostBuilderAdapter(builder);
foreach (var extension in _extensions)
{
- extension.Configure(builder);
+ extension.Configure(adapter);
}
// Avoid using Windows EventLog as it can cause exceptions during host stop/disposal.
diff --git a/src/Alba/ConfigurationOverride.cs b/src/Alba/ConfigurationOverride.cs
index e284545..1fca8e1 100644
--- a/src/Alba/ConfigurationOverride.cs
+++ b/src/Alba/ConfigurationOverride.cs
@@ -1,5 +1,4 @@
using Microsoft.Extensions.Configuration;
-using Microsoft.Extensions.Hosting;
namespace Alba;
@@ -35,9 +34,9 @@ public Task Start(IAlbaHost host)
return Task.CompletedTask;
}
- public IHostBuilder Configure(IHostBuilder builder)
+ public void Configure(IAlbaHostBuilder builder)
{
- return builder.ConfigureHostConfiguration(config =>
+ builder.ConfigureConfiguration(config =>
{
config.AddInMemoryCollection(_configDictionary);
});
diff --git a/src/Alba/HttpContextExtensions.cs b/src/Alba/HttpContextExtensions.cs
index 9bdfada..974cde7 100644
--- a/src/Alba/HttpContextExtensions.cs
+++ b/src/Alba/HttpContextExtensions.cs
@@ -61,11 +61,4 @@ public static void StatusCode(this HttpContext context, int statusCode)
context.Response.StatusCode = statusCode;
}
- public static void Write(this HttpResponse response, string content)
- {
- var bytes = Encoding.UTF8.GetBytes(content);
- response.Body.Write(bytes, 0, bytes.Length);
- response.Body.Flush();
- }
-
}
\ No newline at end of file
diff --git a/src/Alba/IAlbaExtension.cs b/src/Alba/IAlbaExtension.cs
index d631ba7..ba8e30d 100644
--- a/src/Alba/IAlbaExtension.cs
+++ b/src/Alba/IAlbaExtension.cs
@@ -1,5 +1,6 @@
-using Microsoft.Extensions.Hosting;
-
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+
namespace Alba;
#region sample_IAlbaExtension
@@ -12,19 +13,38 @@ public interface IAlbaExtension : IDisposable, IAsyncDisposable
///
/// Called during the initialization of an AlbaHost after the application is started,
/// so the application DI container is available. Useful for registering setup or teardown
- /// actions on an AlbaHOst
+ /// actions on an AlbaHost
///
///
///
Task Start(IAlbaHost host);
-
+
///
- /// Allow an extension to alter the application's
- /// IHostBuilder prior to starting the application
+ /// Allow an extension to alter the application under test before it starts.
+ /// Behaves identically for every AlbaHost bootstrapping style.
///
///
- ///
- IHostBuilder Configure(IHostBuilder builder);
+ void Configure(IAlbaHostBuilder builder)
+ {
+ }
+}
+
+///
+/// Hosting-model-agnostic configuration surface handed to Alba extensions
+/// before the application under test starts
+///
+public interface IAlbaHostBuilder
+{
+ ///
+ /// Add or replace services in the application under test
+ ///
+ void ConfigureServices(Action configure);
+
+ ///
+ /// Add configuration sources for the application under test. Sources added
+ /// here take precedence over the application's own configuration.
+ ///
+ void ConfigureConfiguration(Action configure);
}
-#endregion
\ No newline at end of file
+#endregion
diff --git a/src/Alba/IAlbaHost.cs b/src/Alba/IAlbaHost.cs
index 330554b..16e938f 100644
--- a/src/Alba/IAlbaHost.cs
+++ b/src/Alba/IAlbaHost.cs
@@ -16,7 +16,22 @@ public interface IAlbaHost : IHost, IAsyncDisposable
Task Scenario(Action configure);
///
- /// Execute some kind of action before each scenario. This is additive for each call made.
+ /// Open a live server-sent event stream against the application without
+ /// buffering the response. All BeforeEach/BeforeEachAsync actions apply as
+ /// they do for scenarios. Response assertions other than the expected status
+ /// code are not supported; assert on the streamed events instead. Disposing
+ /// the result aborts the request on the server.
+ ///
+ ///
+ ///
+ ///
+ ///
+ Task StreamServerSentEvents(Action configure,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Execute a synchronous action against the HttpContext immediately before each
+ /// scenario's HTTP request executes. This is additive for each call made.
///
///
///
@@ -30,11 +45,14 @@ public interface IAlbaHost : IHost, IAsyncDisposable
IAlbaHost AfterEach(Action afterEach);
///
- /// Run some kind of set up action immediately before executing an HTTP request
+ /// Run an asynchronous set up action against the Scenario before its HTTP request
+ /// executes. All asynchronous actions run before any synchronous BeforeEach action.
+ /// To modify the outgoing HttpContext, register a callback through
+ /// . This is additive for each call made.
///
///
///
- IAlbaHost BeforeEachAsync(Func beforeEach);
+ IAlbaHost BeforeEachAsync(Func beforeEach);
///
/// Execute some clean up action immediately after executing each HTTP execution. This is additive for each call made.
diff --git a/src/Alba/IScenarioResult.cs b/src/Alba/IScenarioResult.cs
index 66c8feb..17523c5 100644
--- a/src/Alba/IScenarioResult.cs
+++ b/src/Alba/IScenarioResult.cs
@@ -1,3 +1,4 @@
+using System.Net.ServerSentEvents;
using System.Xml;
using Microsoft.AspNetCore.Http;
@@ -58,6 +59,19 @@ public interface IScenarioResult
/// Throws if the response cannot be deserialized.
/// Throws if the response is empty.
Task ReadAsJsonAsync();
-
+
+ ///
+ /// Parse the contents of the HttpResponse.Body as a finite stream
+ /// of server-sent events
+ ///
+ IReadOnlyList> ReadAsServerSentEvents();
+
+ ///
+ /// Parse the contents of the HttpResponse.Body as a finite stream of
+ /// server-sent events, deserializing each data payload as JSON with the
+ /// application's serializer options
+ ///
+ IReadOnlyList> ReadAsServerSentEvents();
+
}
#endregion
\ No newline at end of file
diff --git a/src/Alba/Internal/TypeMemberCache.cs b/src/Alba/Internal/TypeMemberCache.cs
new file mode 100644
index 0000000..afc401f
--- /dev/null
+++ b/src/Alba/Internal/TypeMemberCache.cs
@@ -0,0 +1,16 @@
+using System.Collections.Concurrent;
+using System.Reflection;
+
+namespace Alba.Internal;
+
+internal static class TypeMemberCache
+{
+ private static readonly ConcurrentDictionary _members = new();
+
+ public static (PropertyInfo[] Properties, FieldInfo[] Fields) MembersOf(Type type)
+ {
+ return _members.GetOrAdd(type, t => (
+ t.GetProperties().Where(x => x.CanRead).ToArray(),
+ t.GetFields()));
+ }
+}
diff --git a/src/Alba/Scenario.cs b/src/Alba/Scenario.cs
index 560a3f6..257d989 100644
--- a/src/Alba/Scenario.cs
+++ b/src/Alba/Scenario.cs
@@ -2,6 +2,7 @@
using System.Net;
using System.Security.Claims;
using Alba.Assertions;
+using Alba.Internal;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
@@ -17,8 +18,10 @@ public class Scenario : IUrlExpression
private readonly List _assertions = new();
private readonly List> _setups = new();
+ internal List> AsyncPreparations { get; } = new();
private readonly AlbaHost _system;
- private int _expectedStatusCode = 200;
+ // Null means the default expectation: any 200-299 status code
+ private int? _expectedStatusCode;
private bool _ignoreStatusCode;
internal Scenario(AlbaHost system)
@@ -113,10 +116,26 @@ public IUrlExpression Head
}
}
+ ///
+ /// Specify an HTTP QUERY Url
+ ///
+ public IUrlExpression Query
+ {
+ get
+ {
+ ConfigureHttpContext(context => context.HttpMethod("QUERY"));
+ return this;
+ }
+ }
+
internal List Claims { get; } = new();
- internal List RemovedClaims { get; } = new();
+ internal List RemovedClaims { get; } = new();
internal Exception? Exception { get; set; }
+ internal int? ExpectedStatusCode => _expectedStatusCode;
+ internal bool StatusCodeIgnored => _ignoreStatusCode;
+ internal bool HasResponseAssertions => _assertions.Count > 0;
+
SendExpression IUrlExpression.Url([StringSyntax(StringSyntaxAttribute.Uri)]string relativeUrl)
{
@@ -153,17 +172,15 @@ SendExpression IUrlExpression.FormData(T target)
{
var values = new Dictionary();
- var properties = typeof(T).GetProperties().Where(x => x.CanWrite && x.CanRead);
+ var (properties, fields) = TypeMemberCache.MembersOf(typeof(T));
- foreach (var prop in properties)
+ foreach (var prop in properties.Where(x => x.CanWrite))
{
var rawValue = prop.GetValue(target, null);
values.Add(prop.Name, rawValue?.ToString() ?? string.Empty);
}
- var fields = typeof(T).GetFields();
-
foreach (var field in fields)
{
var rawValue = field.GetValue(target);
@@ -263,13 +280,15 @@ public void WriteJson(T input, JsonStyle? jsonStyle)
if (jsonStyle == JsonStyle.Mvc) jsonStrategy = _system.MvcStrategy;
if (jsonStyle == JsonStyle.MinimalApi) jsonStrategy = _system.MinimalApiStrategy;
+ // Serialization happens in the awaited preparation phase before the
+ // request executes; the setup callback applies the resulting stream
+ Stream? stream = null;
+ AsyncPreparations.Add(async () => stream = await jsonStrategy!.WriteAsync(input));
+
ConfigureHttpContext(c =>
{
-
- var stream = jsonStrategy!.Write(input);
-
c.Request.ContentType = "application/json";
- c.Request.Body = stream;
+ c.Request.Body = stream!;
c.Request.Body.Position = 0;
c.Request.ContentLength = c.Request.Body.Length;
});
@@ -292,7 +311,11 @@ internal void RunAssertions(HttpContext context)
var assertionContext = new AssertionContext(context, _assertionRecords);
if (!_ignoreStatusCode)
{
- new StatusCodeAssertion(_expectedStatusCode).Assert(this, assertionContext);
+ IScenarioAssertion statusAssertion = _expectedStatusCode.HasValue
+ ? new StatusCodeAssertion(_expectedStatusCode.Value)
+ : new StatusCodeSuccessAssertion();
+
+ statusAssertion.Assert(this, assertionContext);
}
foreach (var assertion in _assertions) assertion.Assert(this, assertionContext);
@@ -308,6 +331,7 @@ internal void RunAssertions(HttpContext context)
public Scenario StatusCodeShouldBe(HttpStatusCode httpStatusCode)
{
_expectedStatusCode = (int) httpStatusCode;
+ _ignoreStatusCode = false;
return this;
}
@@ -318,6 +342,20 @@ public Scenario StatusCodeShouldBe(HttpStatusCode httpStatusCode)
public void StatusCodeShouldBe(int statusCode)
{
_expectedStatusCode = statusCode;
+ _ignoreStatusCode = false;
+ }
+
+ ///
+ /// Expect any Http Status Code between 200 and 299. This is the default
+ /// expectation for every scenario; call this to restore it after
+ /// StatusCodeShouldBe(...) or IgnoreStatusCode()
+ ///
+ ///
+ public Scenario StatusCodeShouldBeSuccess()
+ {
+ _expectedStatusCode = null;
+ _ignoreStatusCode = false;
+ return this;
}
///
diff --git a/src/Alba/ScenarioExpectationsExtensions.cs b/src/Alba/ScenarioExpectationsExtensions.cs
index 53a1c70..abbe756 100644
--- a/src/Alba/ScenarioExpectationsExtensions.cs
+++ b/src/Alba/ScenarioExpectationsExtensions.cs
@@ -52,16 +52,6 @@ public static Scenario StatusCodeShouldBeOk(this Scenario scenario)
return scenario.StatusCodeShouldBe(HttpStatusCode.OK);
}
- ///
- /// Assert that the Http Status Code is between 200 and 299
- ///
- ///
- ///
- public static Scenario StatusCodeShouldBeSuccess(this Scenario scenario)
- {
- return scenario.AssertThat(new StatusCodeSuccessAssertion());
- }
-
///
/// Assert that the content-type header value of the Http response
/// matches the expected value
diff --git a/src/Alba/ScenarioResult.cs b/src/Alba/ScenarioResult.cs
index 62ff4f6..4c66bf3 100644
--- a/src/Alba/ScenarioResult.cs
+++ b/src/Alba/ScenarioResult.cs
@@ -1,3 +1,5 @@
+using System.Net.ServerSentEvents;
+using System.Text.Json;
using System.Xml;
using System.Xml.Serialization;
using Alba.Internal;
@@ -32,20 +34,7 @@ public Task ReadAsTextAsync()
///
public XmlDocument? ReadAsXml()
{
- Func read = s =>
- {
- var body = s.ReadAllText();
-
- if (body.Contains("Error"))
- {
- return null;
- }
-
- var document = new XmlDocument();
- document.LoadXml(body);
-
- return document;
- };
+ Func read = s => tryParseXml(s.ReadAllText());
return Read(read);
}
@@ -53,22 +42,24 @@ public Task ReadAsTextAsync()
///
public Task ReadAsXmlAsync()
{
- Func> read = async s =>
- {
- var body = await s.ReadAllTextAsync();
+ Func> read = async s => tryParseXml(await s.ReadAllTextAsync());
- if (body.Contains("Error"))
- {
- return null;
- }
+ return Read(read);
+ }
- var document = new XmlDocument();
+ private static XmlDocument? tryParseXml(string body)
+ {
+ var document = new XmlDocument();
+ try
+ {
document.LoadXml(body);
+ }
+ catch (XmlException)
+ {
+ return null;
+ }
- return document;
- };
-
- return Read(read);
+ return document;
}
///
@@ -91,6 +82,22 @@ public Task ReadAsJsonAsync()
return _system.DefaultJson.ReadAsync(this);
}
+ ///
+ public IReadOnlyList> ReadAsServerSentEvents()
+ {
+ // The response body is a fully buffered MemoryStream, so the
+ // synchronous parse never touches server streams
+ return Read(s => SseParser.Create(s).Enumerate().ToList());
+ }
+
+ ///
+ public IReadOnlyList> ReadAsServerSentEvents()
+ {
+ return Read(s => SseParser
+ .Create(s, (_, data) => JsonSerializer.Deserialize(data, _system.StjJsonOptions)!)
+ .Enumerate().ToList());
+ }
+
public T Read(Func read)
{
if (Context.Response.Body.CanSeek || Context.Response.Body is MemoryStream)
diff --git a/src/Alba/Security/AuthenticationStub.cs b/src/Alba/Security/AuthenticationStub.cs
index 2665ad1..fda675c 100644
--- a/src/Alba/Security/AuthenticationStub.cs
+++ b/src/Alba/Security/AuthenticationStub.cs
@@ -3,7 +3,6 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
@@ -31,9 +30,9 @@ void IDisposable.Dispose()
Task IAlbaExtension.Start(IAlbaHost host) => Task.CompletedTask;
- IHostBuilder IAlbaExtension.Configure(IHostBuilder builder)
+ void IAlbaExtension.Configure(IAlbaHostBuilder builder)
{
- return builder.ConfigureServices(services =>
+ builder.ConfigureServices(services =>
{
services.AddSingleton(this);
services.AddTransient();
diff --git a/src/Alba/Security/JwtSecurityStub.cs b/src/Alba/Security/JwtSecurityStub.cs
index a7548ba..abe235e 100644
--- a/src/Alba/Security/JwtSecurityStub.cs
+++ b/src/Alba/Security/JwtSecurityStub.cs
@@ -3,7 +3,6 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Protocols;
@@ -53,9 +52,9 @@ internal void ConfigureJwt(HttpContext context)
context.SetBearerToken(jwt);
}
- IHostBuilder IAlbaExtension.Configure(IHostBuilder builder)
+ void IAlbaExtension.Configure(IAlbaHostBuilder builder)
{
- return builder.ConfigureServices(services =>
+ builder.ConfigureServices(services =>
{
if (_overrideSchemaTargetName != null)
{
@@ -65,7 +64,6 @@ IHostBuilder IAlbaExtension.Configure(IHostBuilder builder)
{
services.PostConfigureAll(PostConfigure);
}
-
});
}
diff --git a/src/Alba/Security/OpenConnectClientCredentials.cs b/src/Alba/Security/OpenConnectClientCredentials.cs
index e1ad9dc..e75a462 100644
--- a/src/Alba/Security/OpenConnectClientCredentials.cs
+++ b/src/Alba/Security/OpenConnectClientCredentials.cs
@@ -1,5 +1,5 @@
using Alba.Internal;
-using IdentityModel.Client;
+using Duende.IdentityModel.Client;
namespace Alba.Security;
diff --git a/src/Alba/Security/OpenConnectExtension.cs b/src/Alba/Security/OpenConnectExtension.cs
index 997096d..0ddb2ee 100644
--- a/src/Alba/Security/OpenConnectExtension.cs
+++ b/src/Alba/Security/OpenConnectExtension.cs
@@ -1,8 +1,6 @@
-using IdentityModel.Client;
+using Duende.IdentityModel.Client;
using Microsoft.AspNetCore.Authentication.JwtBearer;
-using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
@@ -80,9 +78,9 @@ public Task FetchToken(object? tokenCustomization)
public abstract Task FetchToken(HttpClient client, DiscoveryDocumentResponse? disco,
object? tokenCustomization);
- private async Task DetermineJwt(HttpContext context)
+ private async Task DetermineJwt(Scenario scenario)
{
- if (context.Items.TryGetValue(OverrideKey, out var scenarioOverride))
+ if (scenario.Items.TryGetValue(OverrideKey, out var scenarioOverride))
{
return await FetchToken(_client, _disco, scenarioOverride);
}
@@ -91,16 +89,12 @@ private async Task DetermineJwt(HttpContext context)
return _cached;
}
-
- internal async Task ConfigureJwt(HttpContext context)
+
+ internal async Task ConfigureJwt(Scenario scenario)
{
- var token = await DetermineJwt(context);
+ var token = await DetermineJwt(scenario);
if (token.AccessToken is not null)
- context.SetBearerToken(token.AccessToken);
+ scenario.WithBearerToken(token.AccessToken);
}
- IHostBuilder IAlbaExtension.Configure(IHostBuilder builder)
- {
- return builder;
- }
}
\ No newline at end of file
diff --git a/src/Alba/Security/OpenConnectUserPassword.cs b/src/Alba/Security/OpenConnectUserPassword.cs
index 7dab180..c03f2b7 100644
--- a/src/Alba/Security/OpenConnectUserPassword.cs
+++ b/src/Alba/Security/OpenConnectUserPassword.cs
@@ -1,5 +1,5 @@
using Alba.Internal;
-using IdentityModel.Client;
+using Duende.IdentityModel.Client;
namespace Alba.Security;
diff --git a/src/Alba/SendExpression.cs b/src/Alba/SendExpression.cs
index fc9d4bd..77deccb 100644
--- a/src/Alba/SendExpression.cs
+++ b/src/Alba/SendExpression.cs
@@ -1,4 +1,5 @@
using System.Diagnostics.CodeAnalysis;
+using Alba.Internal;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.WebUtilities;
@@ -89,14 +90,13 @@ public SendExpression QueryString(string paramName, string paramValue)
///
public SendExpression QueryString(T target)
{
- var properties = typeof(T).GetProperties().Where(x => x.CanRead);
+ var (properties, fields) = TypeMemberCache.MembersOf(typeof(T));
foreach (var prop in properties)
{
var rawValue = prop.GetValue(target, null);
QueryString(prop.Name, rawValue?.ToString() ?? string.Empty);
}
- var fields = typeof(T).GetFields();
foreach (var field in fields)
{
diff --git a/src/Alba/Serialization/FormatterSerializer.cs b/src/Alba/Serialization/FormatterSerializer.cs
index 206eb1d..40da20e 100644
--- a/src/Alba/Serialization/FormatterSerializer.cs
+++ b/src/Alba/Serialization/FormatterSerializer.cs
@@ -19,7 +19,10 @@ public FormatterSerializer(AlbaHost host, InputFormatter jsonInput, OutputFormat
_output = jsonOutput;
}
- public Stream Write(T body)
+ internal InputFormatter InputFormatter => _input;
+ internal OutputFormatter OutputFormatter => _output;
+
+ public async Task WriteAsync(T body)
{
var stubContext = new DefaultHttpContext();
var stream = new Scenario.RewindableStream();
@@ -28,92 +31,77 @@ public Stream Write(T body)
var writer = new StreamWriter(stream);
var outputContext =
new OutputFormatterWriteContext(stubContext, (_, _) => writer, typeof(T), body);
- _output.WriteAsync(outputContext).GetAwaiter().GetResult();
+ await _output.WriteAsync(outputContext);
return stream;
}
public T Read(ScenarioResult response)
{
- var provider = _host.Services.GetRequiredService();
- var metadata = provider.GetMetadataForType(typeof(T));
+ var body = response.Context.Response.Body;
+ body.Position = 0;
- var standinContext = new DefaultHttpContext();
+ if (body.Length == 0) throw new EmptyResponseException();
+
+ // The formatter gets its own copy because it may close the stream
+ // it reads from; the response body stays untouched for repeated reads
var buffer = new MemoryStream();
- response.Context.Response.Body.CopyTo(buffer);
+ body.CopyTo(buffer);
buffer.Position = 0;
+ body.Position = 0;
- var copy = new MemoryStream();
- buffer.CopyTo(copy);
- buffer.Position = 0;
+ // The formatter's ReadAsync only touches the in-memory buffer, so
+ // blocking here never waits on real I/O
+ var result = readWithFormatter(buffer).GetAwaiter().GetResult();
- try
- {
- standinContext.Request.Body = buffer; // Need to trick the MVC conneg services
+ return processResult(result, response);
+ }
- if (buffer.Length == 0) throw new EmptyResponseException();
+ public async Task ReadAsync(ScenarioResult response)
+ {
+ var body = response.Context.Response.Body;
+ body.Position = 0;
- var inputContext = new InputFormatterContext(standinContext, typeof(T).Name, new ModelStateDictionary(), metadata, (s, _) => new StreamReader(s));
- var result = _input.ReadAsync(inputContext).GetAwaiter().GetResult();
+ if (body.Length == 0) throw new EmptyResponseException();
- if (result.HasError)
- {
- copy.Position = 0;
- var json = copy.ReadAllText();
- throw new AlbaJsonFormatterException(json);
- }
+ var buffer = new MemoryStream();
+ await body.CopyToAsync(buffer);
+ buffer.Position = 0;
+ body.Position = 0;
- if (result.Model is T returnValue) return returnValue;
+ var result = await readWithFormatter(buffer);
- throw new Exception("Unable to deserialize the response body to " + typeof(T).FullName);
- }
- finally
- {
- // This is to enable repeated reads
- copy.Position = 0;
- response.Context.Response.Body = copy;
- }
+ return processResult(result, response);
}
- public async Task ReadAsync(ScenarioResult response)
+ private Task readWithFormatter(Stream buffer)
{
var provider = _host.Services.GetRequiredService();
var metadata = provider.GetMetadataForType(typeof(T));
var standinContext = new DefaultHttpContext();
- var buffer = new MemoryStream();
- await response.Context.Response.Body.CopyToAsync(buffer);
- buffer.Position = 0;
-
- var copy = new MemoryStream();
- await buffer.CopyToAsync(copy);
- buffer.Position = 0;
-
- try
- {
- standinContext.Request.Body = buffer; // Need to trick the MVC conneg services
-
- if (buffer.Length == 0) throw new EmptyResponseException();
+ standinContext.Request.Body = buffer; // Need to trick the MVC conneg services
- var inputContext = new InputFormatterContext(standinContext, typeof(T).Name, new ModelStateDictionary(), metadata, (s, _) => new StreamReader(s));
- var result = await _input.ReadAsync(inputContext);
+ var inputContext = new InputFormatterContext(standinContext, typeof(T).Name, new ModelStateDictionary(),
+ metadata, (s, _) => new StreamReader(s));
- if (result.HasError)
- {
- copy.Position = 0;
- var json = await copy.ReadAllTextAsync();
- throw new AlbaJsonFormatterException(json);
- }
+ return _input.ReadAsync(inputContext);
+ }
- if (result.Model is T returnValue) return returnValue;
+ private static T processResult(InputFormatterResult result, ScenarioResult response)
+ {
+ var body = response.Context.Response.Body;
- throw new Exception("Unable to deserialize the response body to " + typeof(T).FullName);
- }
- finally
+ if (result.HasError)
{
- // This is to enable repeated reads
- copy.Position = 0;
- response.Context.Response.Body = copy;
+ body.Position = 0;
+ var json = body.ReadAllText();
+ body.Position = 0;
+ throw new AlbaJsonFormatterException(json);
}
+
+ if (result.Model is T returnValue) return returnValue;
+
+ throw new Exception("Unable to deserialize the response body to " + typeof(T).FullName);
}
}
\ No newline at end of file
diff --git a/src/Alba/Serialization/IJsonStrategy.cs b/src/Alba/Serialization/IJsonStrategy.cs
index d99f077..ee78905 100644
--- a/src/Alba/Serialization/IJsonStrategy.cs
+++ b/src/Alba/Serialization/IJsonStrategy.cs
@@ -2,7 +2,7 @@ namespace Alba.Serialization;
public interface IJsonStrategy
{
- Stream Write(T body);
+ Task WriteAsync(T body);
T Read(ScenarioResult response);
Task ReadAsync(ScenarioResult scenarioResult);
}
\ No newline at end of file
diff --git a/src/Alba/Serialization/SystemTextJsonSerializer.cs b/src/Alba/Serialization/SystemTextJsonSerializer.cs
index 2145f7f..29c12e0 100644
--- a/src/Alba/Serialization/SystemTextJsonSerializer.cs
+++ b/src/Alba/Serialization/SystemTextJsonSerializer.cs
@@ -9,6 +9,8 @@ public class SystemTextJsonSerializer : IJsonStrategy
{
private readonly JsonSerializerOptions _options;
+ internal JsonSerializerOptions Options => _options;
+
public SystemTextJsonSerializer(IAlbaHost host)
{
var options = host.Services.GetService> ();
@@ -16,11 +18,11 @@ public SystemTextJsonSerializer(IAlbaHost host)
_options = options?.Value.SerializerOptions ?? new JsonSerializerOptions(JsonSerializerDefaults.Web);
}
- public Stream Write(T body)
+ public Task WriteAsync(T body)
{
var stream = new MemoryStream();
JsonSerializer.Serialize(new Utf8JsonWriter(stream), body, _options);
- return stream;
+ return Task.FromResult(stream);
}
public T Read(ScenarioResult response)
diff --git a/src/Alba/SseStreamResult.cs b/src/Alba/SseStreamResult.cs
new file mode 100644
index 0000000..f8d138e
--- /dev/null
+++ b/src/Alba/SseStreamResult.cs
@@ -0,0 +1,88 @@
+using System.Diagnostics;
+using System.Net;
+using System.Net.ServerSentEvents;
+using System.Text.Json;
+using Microsoft.AspNetCore.Http;
+
+namespace Alba;
+
+///
+/// A live server-sent event stream opened through an AlbaHost. Disposing the
+/// result aborts the in-flight request on the server.
+///
+public sealed class SseStreamResult : IAsyncDisposable
+{
+ private readonly AlbaHost _system;
+ private readonly Stream _stream;
+ private readonly HttpMessageInvoker _invoker;
+ private readonly Activity? _activity;
+ private readonly IReadOnlyList> _afterEach;
+ private bool _consumed;
+ private bool _disposed;
+
+ internal SseStreamResult(AlbaHost system, HttpResponseMessage response, Stream stream,
+ HttpMessageInvoker invoker, Activity? activity, IReadOnlyList> afterEach)
+ {
+ _system = system;
+ Response = response;
+ _stream = stream;
+ _invoker = invoker;
+ _activity = activity;
+ _afterEach = afterEach;
+ }
+
+ ///
+ /// The raw response message for the event stream
+ ///
+ public HttpResponseMessage Response { get; }
+
+ public HttpStatusCode StatusCode => Response.StatusCode;
+
+ ///
+ /// Enumerate the server-sent events as they arrive. The stream can only be
+ /// consumed once.
+ ///
+ public IAsyncEnumerable> ReadEvents(CancellationToken cancellation = default)
+ {
+ return SseParser.Create(claimStream()).EnumerateAsync(cancellation);
+ }
+
+ ///
+ /// Enumerate the server-sent events as they arrive, deserializing each data
+ /// payload as JSON with the application's serializer options. The stream can
+ /// only be consumed once.
+ ///
+ public IAsyncEnumerable> ReadEvents(CancellationToken cancellation = default)
+ {
+ var options = _system.StjJsonOptions;
+ return SseParser
+ .Create(claimStream(), (_, data) => JsonSerializer.Deserialize(data, options)!)
+ .EnumerateAsync(cancellation);
+ }
+
+ private Stream claimStream()
+ {
+ if (_consumed)
+ {
+ throw new InvalidOperationException(
+ "The event stream has already been consumed and can only be read once");
+ }
+
+ _consumed = true;
+ return _stream;
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ if (_disposed) return;
+ _disposed = true;
+
+ // Disposing the response aborts the in-flight request, cancelling the
+ // server's HttpContext.RequestAborted token
+ Response.Dispose();
+ _invoker.Dispose();
+ _activity?.Dispose();
+
+ foreach (var afterEach in _afterEach) await afterEach(null);
+ }
+}
diff --git a/src/Alba/TimeProviderOverride.cs b/src/Alba/TimeProviderOverride.cs
new file mode 100644
index 0000000..d5e6f16
--- /dev/null
+++ b/src/Alba/TimeProviderOverride.cs
@@ -0,0 +1,55 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using Microsoft.Extensions.Time.Testing;
+
+namespace Alba;
+
+#region sample_TimeProviderOverride
+
+///
+/// Alba extension that replaces the application's registration
+/// with this controllable for time-travel testing. Pass an
+/// instance to AlbaHost.For(...), then drive time from the test with Advance(...) and
+/// SetUtcNow(...). Time is frozen unless AutoAdvanceAmount is set.
+///
+public sealed class TimeProviderOverride : FakeTimeProvider, IAlbaExtension
+{
+ ///
+ /// Starts the clock at FakeTimeProvider's default of 2000-01-01T00:00:00Z
+ ///
+ public TimeProviderOverride()
+ {
+ }
+
+ ///
+ /// Starts the clock at the supplied instant
+ ///
+ public TimeProviderOverride(DateTimeOffset startDateTime) : base(startDateTime)
+ {
+ }
+
+ void IAlbaExtension.Configure(IAlbaHostBuilder builder)
+ {
+ builder.ConfigureServices(services =>
+ {
+ services.RemoveAll();
+ services.AddSingleton(this);
+ });
+ }
+
+ Task IAlbaExtension.Start(IAlbaHost host)
+ {
+ return Task.CompletedTask;
+ }
+
+ void IDisposable.Dispose()
+ {
+ }
+
+ ValueTask IAsyncDisposable.DisposeAsync()
+ {
+ return ValueTask.CompletedTask;
+ }
+}
+
+#endregion
diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props
new file mode 100644
index 0000000..402d2f7
--- /dev/null
+++ b/src/Directory.Packages.props
@@ -0,0 +1,36 @@
+
+
+
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/IdentityServer.New/IdentityServer.New.csproj b/src/IdentityServer.New/IdentityServer.New.csproj
index 9fcce5f..7c05cd1 100644
--- a/src/IdentityServer.New/IdentityServer.New.csproj
+++ b/src/IdentityServer.New/IdentityServer.New.csproj
@@ -1,15 +1,14 @@
-
+
- net8.0
+ net10.0
enable
-
-
-
-
+
+
+
diff --git a/src/MinimalApiWithOakton/MinimalApiWithOakton.csproj b/src/MinimalApiWithOakton/MinimalApiWithOakton.csproj
index db94eee..793474b 100644
--- a/src/MinimalApiWithOakton/MinimalApiWithOakton.csproj
+++ b/src/MinimalApiWithOakton/MinimalApiWithOakton.csproj
@@ -1,14 +1,14 @@
-
+
- net8.0;net9.0;net10.0
+ net10.0
enable
enable
-
-
+
+
diff --git a/src/MinimalApiWithOakton/Program.cs b/src/MinimalApiWithOakton/Program.cs
index 107a32b..9fa7c75 100644
--- a/src/MinimalApiWithOakton/Program.cs
+++ b/src/MinimalApiWithOakton/Program.cs
@@ -1,3 +1,5 @@
+using System.Net.ServerSentEvents;
+using System.Runtime.CompilerServices;
using JasperFx;
using Lamar.Microsoft.DependencyInjection;
using Microsoft.AspNetCore.Http.Json;
@@ -17,16 +19,78 @@ public static Task Main(string[] args)
options.SerializerOptions.IncludeFields = true;
});
+ builder.Services.AddSingleton();
+ builder.Services.AddSingleton(TimeProvider.System);
+
var app = builder.Build();
app.MapGet("/", () => "Hello World!");
app.MapGet("/args", () => Results.Ok(args));
app.MapPost("/go", (PostedMessage input) => new OutputMessage(input.Id));
+ app.MapGet("/time", (TimeProvider timeProvider) => new CurrentTime(timeProvider.GetUtcNow()));
+
+ app.MapMethods("/api/query", ["QUERY"], async (HttpContext context) =>
+ {
+ using var reader = new StreamReader(context.Request.Body);
+ var body = await reader.ReadToEndAsync(context.RequestAborted);
+ return $"I ran a QUERY with value {body}";
+ });
+
+ app.MapGet("/sse/finite", () => TypedResults.ServerSentEvents(
+ Enumerable.Range(1, 3)
+ .Select(i => new SseItem(new Counter { Number = i }, "count") { EventId = i.ToString() })
+ .ToAsyncEnumerable()));
+
+ app.MapGet("/sse/infinite", (SseAbortTracker tracker) =>
+ TypedResults.ServerSentEvents(InfiniteCounters(tracker)));
+
+ app.MapGet("/sse/echo-header", (HttpContext context) => TypedResults.ServerSentEvents(
+ new[] { context.Request.Headers["x-sse-echo"].ToString() }.ToAsyncEnumerable()));
+
return app.RunJasperFxCommands(args);
}
+
+ private static async IAsyncEnumerable> InfiniteCounters(SseAbortTracker tracker,
+ [EnumeratorCancellation] CancellationToken cancellation = default)
+ {
+ try
+ {
+ var number = 0;
+ while (true)
+ {
+ number++;
+ yield return new SseItem(new Counter { Number = number });
+ await Task.Delay(25, cancellation);
+ }
+ }
+ finally
+ {
+ tracker.MarkAborted();
+ }
+ }
}
-
+
public record PostedMessage(Guid Id);
public record OutputMessage(Guid Id);
+ public record CurrentTime(DateTimeOffset UtcNow);
+
+ public class Counter
+ {
+ public int Number;
+ }
+
+ ///
+ /// Lets tests observe that an SSE request was aborted server-side
+ ///
+ public class SseAbortTracker
+ {
+ private TaskCompletionSource _aborted = new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ public Task Aborted => _aborted.Task;
+
+ public void MarkAborted() => _aborted.TrySetResult();
+
+ public void Reset() => _aborted = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ }
}
diff --git a/src/NUnitSamples/NUnitSamples.csproj b/src/NUnitSamples/NUnitSamples.csproj
index faab9f1..4102e8b 100644
--- a/src/NUnitSamples/NUnitSamples.csproj
+++ b/src/NUnitSamples/NUnitSamples.csproj
@@ -1,15 +1,15 @@
- net8.0;net9.0;net10.0
+ net10.0
false
-
-
-
+
+
+
diff --git a/src/TUnitSamples/TUnitSamples.csproj b/src/TUnitSamples/TUnitSamples.csproj
index b149f6f..e7f5b75 100644
--- a/src/TUnitSamples/TUnitSamples.csproj
+++ b/src/TUnitSamples/TUnitSamples.csproj
@@ -1,15 +1,15 @@
-
+
Exe
- net8.0;net9.0;net10.0
+ net10.0
enable
enable
-
+
diff --git a/src/WebApiAspNetCore3/WebApiStartupHostingModel.csproj b/src/WebApiAspNetCore3/WebApiStartupHostingModel.csproj
index 5ac1897..9a13203 100644
--- a/src/WebApiAspNetCore3/WebApiStartupHostingModel.csproj
+++ b/src/WebApiAspNetCore3/WebApiStartupHostingModel.csproj
@@ -1,7 +1,7 @@
-
+
- net8.0
+ net10.0
diff --git a/src/WebApiNet6/WebApiNet6.csproj b/src/WebApiNet6/WebApiNet6.csproj
index e92e6be..9d11066 100644
--- a/src/WebApiNet6/WebApiNet6.csproj
+++ b/src/WebApiNet6/WebApiNet6.csproj
@@ -1,7 +1,7 @@
-
+
- net8.0;net9.0;net10.0
+ net10.0
enable
enable
diff --git a/src/WebApp/WebApp.csproj b/src/WebApp/WebApp.csproj
index 985862b..25de370 100644
--- a/src/WebApp/WebApp.csproj
+++ b/src/WebApp/WebApp.csproj
@@ -1,7 +1,7 @@
-
+
- net8.0
+ net10.0
true
WebApp
Exe
@@ -9,9 +9,8 @@
-
-
-
+
+
diff --git a/src/WebAppSecuredWithJwt/SseController.cs b/src/WebAppSecuredWithJwt/SseController.cs
new file mode 100644
index 0000000..e022644
--- /dev/null
+++ b/src/WebAppSecuredWithJwt/SseController.cs
@@ -0,0 +1,24 @@
+using System.Linq;
+using System.Net.ServerSentEvents;
+using System.Security.Claims;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace WebApi
+{
+ public class SseController : ControllerBase
+ {
+ [Authorize]
+ [HttpGet("/sse/secured")]
+ public IResult GetEvents()
+ {
+ var foo = User.FindFirstValue("foo") ?? "none";
+ var items = Enumerable.Range(1, 2)
+ .Select(i => new SseItem($"{foo}-{i}") { EventId = i.ToString() })
+ .ToAsyncEnumerable();
+
+ return TypedResults.ServerSentEvents(items);
+ }
+ }
+}
diff --git a/src/WebAppSecuredWithJwt/WebAppSecuredWithJwt.csproj b/src/WebAppSecuredWithJwt/WebAppSecuredWithJwt.csproj
index 9799c88..57aca44 100644
--- a/src/WebAppSecuredWithJwt/WebAppSecuredWithJwt.csproj
+++ b/src/WebAppSecuredWithJwt/WebAppSecuredWithJwt.csproj
@@ -1,13 +1,13 @@
-
+
- net8.0
+ net10.0
-
-
-
+
+
+
diff --git a/v9_CHANGELOG.md b/v9_CHANGELOG.md
new file mode 100644
index 0000000..b76860a
--- /dev/null
+++ b/v9_CHANGELOG.md
@@ -0,0 +1,204 @@
+# Alba v9 Changelog
+
+## Breaking changes
+
+### .NET 10 is required
+
+Alba now targets `net10.0` only; support for .NET 8 and .NET 9 is dropped. Applications under
+test must run on .NET 10.
+
+### `BeforeEachAsync` now receives the `Scenario` and runs before the request
+
+`IAlbaHost.BeforeEachAsync` changed from `Func` to `Func`. In
+Alba 8 the hook received the `HttpContext` and was executed by blocking a thread inside the test
+server; it is now properly awaited *before* the HTTP request executes.
+
+If your hook touched the `HttpContext`, do the asynchronous work first, then apply the result to
+the outgoing request:
+
+```cs
+// Alba 8
+host.BeforeEachAsync(async context =>
+{
+ var token = await FetchTokenAsync();
+ context.SetBearerToken(token);
+});
+
+// Alba 9
+host.BeforeEachAsync(async scenario =>
+{
+ var token = await FetchTokenAsync();
+ scenario.WithBearerToken(token);
+});
+```
+
+For arbitrary context mutations, register a callback with `scenario.ConfigureHttpContext(...)`.
+
+Ordering: all asynchronous `BeforeEachAsync` actions run first (in registration order), then all
+synchronous `BeforeEach` actions (in registration order). In Alba 8 the two kinds interleaved in
+registration order.
+
+### Synchronous bootstrapping removed
+
+The `new AlbaHost(IHostBuilder)` constructor, the `StartAlba()` extension method, and the
+synchronous `AlbaHost.For(Action)` overload are removed. Bootstrapping is
+asynchronous only:
+
+```cs
+await using var host = await AlbaHost.For(hostBuilder);
+// or
+await using var host = await hostBuilder.StartAlbaAsync();
+```
+
+Use your test framework's asynchronous initialization support (e.g. xUnit's `IAsyncLifetime`) to
+start the host.
+
+### `AlbaHost.For(Action)` removed
+
+This overload implicitly created a `Host.CreateDefaultBuilder()` host and could not accept Alba
+extensions. Build the host builder explicitly instead:
+
+```cs
+// Alba 8
+var host = await AlbaHost.For(w => w.UseStartup());
+
+// Alba 9
+var host = await AlbaHost.For(Host.CreateDefaultBuilder()
+ .ConfigureWebHostDefaults(w => w.UseStartup()));
+```
+
+### `AllowSynchronousIO` is no longer forced on
+
+Alba no longer performs any synchronous I/O against server streams and no longer sets
+`AllowSynchronousIO = true` on the `TestServer`. If the application under test performs
+synchronous reads or writes against the request/response streams in its own pipeline, opt back
+in explicitly after starting the host:
+
+```cs
+host.Server.AllowSynchronousIO = true;
+```
+
+### Extension model redesigned around `IAlbaHostBuilder`
+
+`IAlbaExtension.Configure` changed from `IHostBuilder Configure(IHostBuilder builder)` to
+`void Configure(IAlbaHostBuilder builder)`. The new `IAlbaHostBuilder` surface is
+hosting-model-agnostic and behaves identically for every bootstrapping style (`IHostBuilder`,
+`WebApplicationBuilder`, and `WebApplicationFactory`); in Alba 8 the `WebApplicationBuilder` path
+went through a partial `IHostBuilder` facade and the `WebApplicationFactory` path silently ignored
+the returned builder.
+
+```cs
+// Alba 8
+public IHostBuilder Configure(IHostBuilder builder)
+{
+ return builder.ConfigureServices(services => services.AddSingleton());
+}
+
+// Alba 9
+public void Configure(IAlbaHostBuilder builder)
+{
+ builder.ConfigureServices(services => services.AddSingleton());
+}
+```
+
+Configuration overrides use `builder.ConfigureConfiguration(c => c.AddInMemoryCollection(...))`,
+which takes precedence over the application's own configuration on every path. `Configure` now has
+a default no-op implementation, so extensions that only need post-start logic can implement
+`Start` alone.
+
+### `IJsonStrategy.Write` is now asynchronous
+
+`IJsonStrategy.Write(T body)` changed to `Task WriteAsync(T body)`. This only
+affects custom `IJsonStrategy` implementations; request JSON serialization now happens in an
+awaited preparation phase before the request executes instead of blocking inside the test
+server's setup callback.
+
+### `IdentityModel` replaced by `Duende.IdentityModel`
+
+Alba's OpenID Connect extensions now use the `Duende.IdentityModel` package (the renamed successor
+of `IdentityModel`). This is visible in the public API of `OpenConnectExtension` and its
+subclasses: types like `TokenResponse` and `DiscoveryDocumentResponse` now come from the
+`Duende.IdentityModel.Client` namespace. If you override `FetchToken` or consume the token types,
+update your `using IdentityModel.Client;` directives to `using Duende.IdentityModel.Client;`.
+
+### `HttpResponse.Write` extension removed
+
+The `Alba.HttpContextExtensions.Write(this HttpResponse, string)` helper performed synchronous
+stream writes. Use ASP.NET Core's built-in `HttpResponse.WriteAsync(...)` instead.
+
+### `Dispose()` performs a full, awaited shutdown
+
+`IAlbaHost.Dispose()` previously fired the host stop without awaiting it. It now performs the
+complete teardown (equivalent to awaiting `DisposeAsync()`), including disposing extensions via
+`DisposeAsync`. Prefer `await using` / `DisposeAsync()` in new code.
+
+### The default status code expectation accepts any 2xx
+
+Scenarios that do not configure a status code expectation now pass for any status code between
+200 and 299 instead of requiring exactly 200, and the default failure message changed to
+"Expected a status code between 200 and 299, but was ...". Require one exact code with
+`StatusCodeShouldBe(...)` or `StatusCodeShouldBeOk()`:
+
+```cs
+await host.Scenario(x =>
+{
+ x.Get.Url("/");
+
+ // Only needed when exactly 200 is required; any 2xx passes by default
+ x.StatusCodeShouldBeOk();
+});
+```
+
+`StatusCodeShouldBeSuccess()` restores the default success-range expectation after
+`StatusCodeShouldBe(...)` or `IgnoreStatusCode()`, no longer registers a scenario assertion (so
+it also works with `StreamServerSentEvents`), and moved from an extension method to an instance
+method on `Scenario`. This also fixes
+[#228](https://github.com/JasperFx/alba/issues/228), where `StatusCodeShouldBeSuccess()` ran the
+default exact-200 assertion alongside the range check and failed 201/202/204 responses.
+
+### `ReadAsXml` returns null on unparseable bodies instead of matching on "Error"
+
+`IScenarioResult.ReadAsXml()` / `ReadAsXmlAsync()` previously returned `null` whenever the response
+body merely *contained* the substring "Error", and threw on any other unparseable body. They now
+attempt to parse and return `null` only when the body is not valid XML. Valid XML documents that
+happen to contain the text "Error" now parse successfully.
+
+## New features
+
+- **Server-sent events support.** Finite streams: `IScenarioResult.ReadAsServerSentEvents()` and
+ `ReadAsServerSentEvents()` parse a buffered response body as `SseItem` values, with the
+ typed overload deserializing each data payload through the application's JSON options. Live
+ streams: `IAlbaHost.StreamServerSentEvents(Action, CancellationToken)` opens an
+ unbuffered event stream that yields events as the application writes them; all
+ `BeforeEach`/`BeforeEachAsync` actions and the security extensions apply exactly as they do for
+ scenarios. Disposing the returned `SseStreamResult` aborts the request on the server (cancelling
+ `HttpContext.RequestAborted`) and then runs `AfterEach`/`AfterEachAsync` actions with a `null`
+ `HttpContext`. Response assertions other than the expected status code are not supported on the
+ streaming path and are rejected up front.
+- **HTTP QUERY support.** Scenarios can issue HTTP QUERY requests via `Scenario.Query`, matching
+ the existing verb properties (`x.Query.Url("/api/query")`).
+- **Time-travel testing with `TimeProviderOverride`.** A `FakeTimeProvider`-based extension that
+ replaces the application's `TimeProvider` registration on every bootstrapping style. The
+ extension is the clock: pass it to `AlbaHost.For(...)`, then drive time from the test with
+ `Advance(...)` and `SetUtcNow(...)`
+ ([#230](https://github.com/JasperFx/alba/issues/230)). Alba now depends on the
+ `Microsoft.Extensions.TimeProvider.Testing` package.
+
+## Improvements
+
+- The response body is buffered once, asynchronously, immediately after each request completes.
+ All response reads — including the synchronous `ReadAsText()`, `ReadAsJson()`, and body
+ assertions — are seekable, repeatable, memory-only operations.
+- Scenario setup exceptions from asynchronous before-each actions surface directly with their
+ original stack traces instead of being marshalled out of the test server callback.
+
+## Bug fixes
+
+- Alba's MVC JSON strategy no longer selects third-party formatters (such as
+ Microsoft.AspNetCore.OData's) that advertise `application/json` but cannot run outside their
+ own pipeline. The framework's System.Text.Json or Newtonsoft.Json formatters are preferred
+ regardless of registration order, fixing `PostJson`/`ReadAsJson` in applications that register
+ OData ([#116](https://github.com/JasperFx/alba/issues/116)). Applications that intentionally
+ front-loaded a custom JSON formatter alongside the framework one and relied on Alba picking
+ the custom one now get the framework formatter; remove the framework JSON formatters if the
+ custom one should win.