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.