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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/content/docs/changelog/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ Notable changes to the kit, newest first.

## 2026-11-04

- **Tests: the test projects are on xUnit v3 (chore).** Every project under `src/Tests` moves from `xunit` 2.9.3 to `xunit.v3.mtp-off` 4.0.2. The `mtp-off` flavour keeps VSTest, so `dotnet test`, `--filter`, the coverlet collector and `coverage.runsettings` work as before, and scaffolded projects need no `global.json` test-runner setting. Test projects are now executables (`<OutputType>Exe</OutputType>`). `IAsyncLifetime` returns `ValueTask` in v3, so `FshWebApplicationFactory` and `MiddlewareWebApplicationFactory` now override `WebApplicationFactory.DisposeAsync()` instead of hiding it. The analyzer rule `xUnit1051` is off for `src/Tests`, because its code fix would make NSubstitute setups expect the test's cancellation token. v3 also runs tests in a different order, which exposed four hidden dependencies between integration tests. Derived `WithWebHostBuilder` hosts left Hangfire's `JobStorage.Current` and the static `Audit` publisher pointing at their disposed storage and channel, so later enqueues failed and request audits were lost. Provisioning-failure tests left tenants with an unreachable database behind for all-tenant jobs to trip over. An audit poll assumed a new tenant's rows fit on one page. And a rate-limit test shared a partition with the test that exhausts it. All four are fixed in the test harness; production code is unchanged. If you added your own test projects or fixtures, make the same changes. See [Unit tests](/docs/testing/unit-tests/#xunit-v3-specifics), [Fixtures](/docs/testing/fixtures-and-seed-data/) and [#1457](https://github.com/fullstackhero/dotnet-starter-kit/issues/1457).
- **Frontends: smaller initial load after the Vite 8 upgrade (perf).** Rolldown had split the code each app loads up front into 45 (admin) and 68 (dashboard) small chunks, so initial load grew past the Vite 7 numbers. `vite.config.ts` now groups everything the entry statically imports into one `vendor` and one `app` chunk. Only the app shell's `en-US` translation namespaces ship in the main bundle, and a route's own namespaces load alongside its page chunk. Admin now loads the SignalR client on first connect, as the dashboard already did. Initial load (gzip) drops from 265.4 to 225.1 KiB in admin and from 271.3 to 229.9 KiB in the dashboard, below the pre-upgrade 242.9 and 245.2 KiB. **If you forked the apps:** a new route namespace goes in the lazy catalog map in `src/i18n.ts` and is listed on its routes, as in `lazyNamed(importer, name, ["ns"])`. The new `tests/i18n/route-namespaces.spec.ts` fails when a route misses one. See [Internationalization](/docs/cross-cutting-concerns/internationalization/#catalogs), [#1462](https://github.com/fullstackhero/dotnet-starter-kit/issues/1462) and [#1466](https://github.com/fullstackhero/dotnet-starter-kit/pull/1466).

## 2026-11-03
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ Every request the app makes through `apiFetch` sends the **active language as th

A language switcher sits in the **topbar**. Changing the language does two things beyond re-rendering the UI:

1. Persists the choice to `User.Locale` by calling the profile update endpoint (`PUT /identity/profile`). The dashboard reads the profile with its `ETag` and sends it back as `If-Match`, so a stale write is rejected with 412 instead of overwriting a concurrent change; the admin switcher does not send `If-Match` yet. A failed save raises a toast.
1. Persists the choice to `User.Locale` by calling the profile update endpoint (`PUT /identity/profile`). Both apps read the profile with its `ETag` and send it back as `If-Match`, so a stale write is rejected with 412 instead of overwriting a concurrent change. The admin switcher then retries once against the fresh profile. A failed save raises a toast.
2. Refreshes the access token, so the new preference is baked into the JWT `locale` claim and the backend honours it from the next request. The refresh is best-effort: if it fails, the session stays signed in and the claim catches up at the next natural refresh, so until then an API error can still come back in the previous language.

While an operator is impersonating a user in the dashboard, the switcher only changes the UI language: it sends no `PUT`, so the operator's choice never lands on the impersonated user's profile.
Expand Down
14 changes: 14 additions & 0 deletions src/content/docs/testing/fixtures-and-seed-data.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,20 @@ Finbuckle's `IMultiTenantContextSetter` writes an `AsyncLocal`. Set it **in the
## When to add to the harness

- **New shared infrastructure.** If you need RabbitMQ, Elasticsearch, etc., add the container as another field on `FshWebApplicationFactory`, start it in `InitializeAsync`, and overlay its connection details - same shape as the RustFS wiring. For something only one test class needs (like the Valkey container in `HybridCacheRedisTests`), keep it local to that class via `IAsyncLifetime` instead.

In xUnit v3, `IAsyncLifetime` methods return `ValueTask` and the interface extends `IAsyncDisposable`:

```csharp
public sealed class HybridCacheRedisTests : IAsyncLifetime
{
private readonly RedisContainer _redis = new RedisBuilder("valkey/valkey:9.1.3-alpine").Build();

public async ValueTask InitializeAsync() => await _redis.StartAsync();
public ValueTask DisposeAsync() => _redis.DisposeAsync();
}
```

`FshWebApplicationFactory` already has a `DisposeAsync()` from `WebApplicationFactory<Program>`, so it **overrides** it: it calls `base.DisposeAsync()` first (stops the host), then disposes the Postgres and RustFS containers.
- **New always-present seed data.** Extend the module's `IDbInitializer` seed - that's the production path, and the factory runs it automatically.
- **One-off setup per test class.** Don't touch the harness; arrange it in the test class constructor or the test itself.

Expand Down
3 changes: 2 additions & 1 deletion src/content/docs/testing/integration-tests.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,8 @@ The factory overrides configuration and services so tests are deterministic:

- **Don't share state across tests.** The whole suite shares one database, and a single collection means hundreds of tests touch the same rows over a run. Use unique names per test (`UniqueName("...")` / a `Guid` suffix) so tests stay independent and re-runnable.
- **Don't mutate seeded data.** The root tenant and root admin are shared by every test. Create your own tenant/user/brand when you need to mutate one.
- **Don't trust class ordering.** xUnit doesn't guarantee execution order across classes. If a test needs another test to "have created X first," the test is wrong.
- **Don't trust class ordering.** xUnit doesn't guarantee execution order across classes. If a test needs another test to "have created X first," the test is wrong. The reverse matters too: clean up shared state a later test could trip over. A tenant pointed at an unreachable database, for example, goes through `_factory.RemoveTenantsAsync(ids)` in the class's `DisposeAsync`; otherwise jobs that walk every tenant count it as a failure.
- **Dispose derived hosts.** A host from `_factory.WithWebHostBuilder(...)` shares two process-wide statics with the shared host: Hangfire's `JobStorage.Current` and the static `Audit` publisher. The harness's `PrimaryHostStaticsRestorer` points both back at the shared host when a derived host stops, so always `await using` the derived factory.

## When integration tests get slow

Expand Down
2 changes: 1 addition & 1 deletion src/content/docs/testing/running-tests.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ dotnet test --filter "FullyQualifiedName~Catalog & FullyQualifiedName~Product"

## Parallelism

xUnit runs test classes in parallel within a single project by default. Tests within a class run sequentially unless marked otherwise.
xUnit v3 runs test classes in parallel within a single project by default. Tests within a class run sequentially unless marked otherwise.

To disable parallelism across classes (e.g. for shared state):

Expand Down
7 changes: 6 additions & 1 deletion src/content/docs/testing/unit-tests.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,18 @@ Unit tests in fullstackhero are **fast, in-process, no infrastructure**. They te

| Library | Used for |
|---|---|
| xUnit 2.x | Test runner, `[Fact]` / `[Theory]` discovery |
| xUnit v3 (`xunit.v3.mtp-off`) | Test runner, `[Fact]` / `[Theory]` discovery. Runs on VSTest, so `dotnet test` and the coverlet collector work as before |
| Shouldly 4.x | Readable assertions - `result.ShouldBe(...)`, `.ShouldThrow<>()`, `.ShouldSatisfyAllConditions(...)` |
| NSubstitute 6.x | Mocking service interfaces, verifying calls |
| AutoFixture 4.x | Random DTO / fixture generation |

All four come from the kit's `Directory.Packages.props` central package management.

### xUnit v3 specifics

- Test projects are executables: each csproj sets `<OutputType>Exe</OutputType>`.
- The analyzer rule `xUnit1051` (pass `TestContext.Current.CancellationToken` to every async call) is turned off for `src/Tests` in `src/.editorconfig`. Its code fix also rewrites NSubstitute setups and `Received()` checks to expect that token, which changes what the mock matches, since handlers under test are called with `CancellationToken.None`. Pass the token yourself when a test does long-running IO that should stop on cancellation.

## Naming convention

`MethodName_Should_ExpectedBehavior_When_Condition`. The shape forces you to write the assertion as a sentence:
Expand Down
2 changes: 1 addition & 1 deletion src/content/docs/testing/writing-new-tests.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Unit test projects group by *kind*, not by feature path - `Domain/` for aggregat
src/Tests/Catalog.Tests/Handlers/AdjustProductStockCommandHandlerTests.cs
```

(If the module has no test project yet, create it with `dotnet new xunit -n Catalog.Tests -o src/Tests/Catalog.Tests`, add it to the solution, and reference the module plus `Shouldly`, `NSubstitute`, and `AutoFixture` - versions come from central package management.)
(If the module has no test project yet, copy an existing unit test csproj (for example `src/Tests/Tickets.Tests`) rather than running `dotnet new xunit`, which still scaffolds xUnit v2. Keep `<OutputType>Exe</OutputType>` and the `xunit.v3.mtp-off`, `xunit.runner.visualstudio`, `Microsoft.NET.Test.Sdk` and `coverlet.collector` references, add it to the solution, and reference the module plus `Shouldly`, `NSubstitute`, and `AutoFixture` - versions come from central package management.)

### 2. Write the test

Expand Down