diff --git a/.agents/skills/purview-architecture-testing/SKILL.md b/.agents/skills/purview-architecture-testing/SKILL.md index de41062..0619225 100644 --- a/.agents/skills/purview-architecture-testing/SKILL.md +++ b/.agents/skills/purview-architecture-testing/SKILL.md @@ -48,8 +48,8 @@ validates it as you type: ```json { - "$schema": "https://raw.githubusercontent.com/purview-dev/build-sdk/main/architecture-policy.schema.json", - "version": 1, + "$schema": "https://raw.githubusercontent.com/purview-dev/architests/main/architecture-policy.schema.json", + "version": 2, "sourceRoots": ["src"], "responsibilities": [ { diff --git a/.agents/skills/purview-testing-conventions/SKILL.md b/.agents/skills/purview-testing-conventions/SKILL.md index 078f85f..1fe3232 100644 --- a/.agents/skills/purview-testing-conventions/SKILL.md +++ b/.agents/skills/purview-testing-conventions/SKILL.md @@ -4,7 +4,8 @@ description: >- Use when writing or reviewing tests in a .NET repository following the Purview conventions - TUnit and TUnit.Mocks, the AAA structure and naming, placing a test in the namespace of the class under test, CreateXXX construction helpers, building domain content through TestBuilder with Bogus, the - assertion surface, parameterised tests, and which tier a test project belongs to. + assertion surface, parameterised tests, which tier a test project belongs to, BDTest for + acceptance tests, and Purview.Containers for a suite that needs a real database or queue. metadata: product: Purview engineering playbook repository: purview-dev/engineering-playbook @@ -17,18 +18,23 @@ metadata: | Framework | **TUnit**. Not xUnit, NUnit or MSTest. | | Substitutes | **TUnit.Mocks**. Not NSubstitute or Moq. | | Test data | **Bogus**, reached through `TestBuilder`. | -| Structure | Explicit `// Arrange`, `// Act`, `// Assert`. | +| Acceptance tests | **BDTest** over TUnit, via `BDTest.TUnit`. Not Gherkin, SpecFlow or Reqnroll. | +| A real dependency | **`Purview.Containers`**. Not Testcontainers directly, never a shared instance. | +| Structure | Explicit `// Arrange`, `// Act`, `// Assert` — Given/When/Then in an acceptance test. | | Naming | `{SubjectUnderTest}_{Scenario}_{Expectation}`, and `async Task`. | | Placement | One `{Class}Tests` class per class under test, **in the namespace of the class under test**. | | Construction | Dependencies and the system under test come from `CreateXXX` helpers with optional parameters. | -| Cancellation | Pass a `CancellationToken` wherever a downstream method accepts one. | +| Async | The async overload wherever the API offers one — in the test **and in every helper it calls**. Convert a sync helper, or state why it cannot be. | +| Cancellation | If any method the test calls accepts a `CancellationToken`, the test's **last parameter** is one, forwarded to every call that accepts one. | | Domain content | Built through `TestBuilder`. Never inline. | ## Why these, specifically **One framework.** The SDK wires TUnit into any project whose name ends in a test suffix — that wiring is why a test project file is empty. A second framework means every project file grows a -conditional and the pipeline grows a second discovery path. +conditional and the pipeline grows a second discovery path. That is a rule about the **runner**, not +the style of a test — `BDTest.TUnit` is an adapter *over* TUnit, so an acceptance suite does not break +it. **Same namespace as the subject.** The test reads as part of the thing it describes, needs no `using` to reach the type, and **cannot silently test a different type with the same name**. @@ -122,6 +128,80 @@ public async Task Create_GivenSlugThatIsNotALowercaseSlug_Throws(string value) } ``` +## Async all the way down + +**Where an API offers an async overload, use it** — `File.ReadAllTextAsync`, not `File.ReadAllText`. +**And the rule reaches through test helpers:** a helper calling such an API is itself converted. Being +a helper is not an exemption. + +The build enforces the first half and cannot enforce the second, which is why the second needs saying: + +- `VSTHRD103` is an **`error`** and flags a sync call with an `Async`-suffixed equivalent **when the + call sits inside a `Task`-returning method**. In an `async Task` test, `File.ReadAllText` fails the + build. +- A **sync helper is not `Task`-returning**, so the identical call inside it is invisible to the + analyzer. Pushing the call one level down turns a build error into nothing. + +It also compounds with the cancellation rule, which keys off whether anything in the chain accepts a +`CancellationToken`: a sync helper accepts none, so it **silently removes the trigger** and the test is +uncancellable while looking compliant. (TUnit also runs tests in parallel, so a blocking read holds a +thread-pool thread an async read would release.) + +Converting — three things change together, and the first two are not optional: + +```csharp +// Before — invisible to VSTHRD103, and the chain accepts no token +static string Documentation => File.ReadAllText(DocumentationPath); + +// After +static async Task ReadDocumentationAsync(CancellationToken cancellationToken) => + await File.ReadAllTextAsync(DocumentationPath, cancellationToken); +``` + +- It returns a `Task`, bringing every call inside it back under `VSTHRD103`. +- It gains the **`Async` suffix** — `VSTHRD200` is an `error`, so the rename is enforced. +- It takes a trailing `CancellationToken`, because the chain now accepts one; its callers declare and + forward one. + +**A property cannot be `async`, so it becomes a method** — that is the conversion, not an exemption. +A read-only property hiding IO is the most common form of this violation. + +It genuinely cannot be converted when **no async overload exists** (`File.Exists`, `File.Delete`, +`Directory.EnumerateFiles`; likewise `ArchitecturePolicy.Load()` and `PolicySchemaValidator.Validate()`, +which `Purview.Architecture.Testing` ships synchronous-only) or when **a base type or interface fixes +the signature**. State the reason at the call. Never fake it: `Task.Run` around a blocking call, +`Task.FromResult` around a sync result, or `.Result` / `.GetAwaiter().GetResult()` to flatten a task — +the last two are `VSTHRD002`, an `error`. + +## Cancellation + +Three parts, triggered by the **call chain** rather than by the test: + +1. **If any method the test calls accepts a `CancellationToken`, the test declares one.** Not only the + method under test — anything in the chain, including a `File.ReadAllTextAsync` or the delegate handed + to `Assert.That`. +2. **It is the last parameter**, and TUnit supplies the value. With `[Arguments]`, the data binds to the + leading parameters and the token is supplied on top, so the attribute's positional order still matches + the signature: + + ```csharp + [Test] + [Arguments("build-enforced", true)] + public async Task ListEnforcedByBuildAsync_GivenAConvention_FiltersIt( + string id, + bool enforcedByBuild, + CancellationToken cancellationToken + ) + ``` + +3. **It is forwarded to every call that accepts one.** Never `CancellationToken.None`, never `default`, + never a token the test creates. TUnit's token is the *test's* token — cancelled on timeout or on an + aborted run — so a call given `None` cannot be stopped, and **one hop that drops it makes the whole + chain uncancellable**, which is why partial forwarding is a failure and not a lesser version of the + rule. It is also the only place that proves the production code threads the token through at all. + +A test whose chain accepts no token takes no parameter; an unused token is noise, not compliance. + ## Substitutes TUnit.Mocks is globally imported by the SDK, so no `using` is needed: @@ -224,14 +304,35 @@ string title = new('a', Convention.MaxTitleLength); // new() -> explici | Test projects are discovered and categorised | the build (name-suffix detection) | | A shared testing project is referenced, not executed | the build (`IsSharedTestingProject`) | | Style, including the `var` carve-out | the build (analyzers, `warning` or above) | +| An acceptance suite is categorised `Acceptance` and kept out of the fast tier | the build (name-suffix detection) and the pipeline's `TestFilter` | +| A container-backed suite fails rather than passes when its backend is missing in CI | the pipeline, when the backend is pinned with `PURVIEW_CONTAINERS_BACKEND` | | AAA structure, naming, `CreateXXX` helpers, `TestBuilder` usage | **review** | -| A `CancellationToken` is passed through | **review** | +| Given/When/Then chains end in `.BDTestAsync()` | **review** | +| A real dependency comes from `Purview.Containers`, not a shared instance | **review** | +| An async overload is used inside an `async Task` test | the build — `VSTHRD103` is `error` | +| A converted helper carries the `Async` suffix | the build — `VSTHRD200` is `error` | +| A **sync helper** calling an API with an async overload is converted | **review** — a non-`Task`-returning method is outside `VSTHRD103`'s scope | +| A declared `CancellationToken` is the last parameter | the build — `CA1068` is `warning`, and a clean build has no warnings | +| A test whose chain accepts a token declares one and forwards it everywhere | **review** | + +## Acceptance tests and container-backed tests + +An acceptance test — a behaviour somebody outside the team asked for, in their words — is written with +**BDTest over TUnit** (`BDTest.TUnit`) in a `.AcceptanceTests` project, where Given/When/Then +replaces the AAA comments. A suite that needs a real database, queue or emulator gets a throwaway +container from **`Purview.Containers`** in its own `.IntegrationTests` project, skipping +locally when no backend is usable and failing in CI. + +Read `references/acceptance-and-container-tests.md` before writing either: it carries the scenario and +container shapes, and the two traps — a chain that never calls `.BDTestAsync()` runs no steps and still +passes, and a suite that skips in CI turns a missing runtime into a green job. ## Tiers -A test project's **name** carries its tier — the SDK categorises `*UnitTests` as `Unit` — so the -pipeline's `TestFilter` selects a tier without a hand-written attribute. Split a slow suite into its -own project rather than tagging individual tests. +A test project's **name** carries its tier — the SDK categorises `*UnitTests` as `Unit`, and takes the +word before `Tests` as a pattern, so `*AcceptanceTests` gives `Acceptance` with nothing added to the +pipeline. The `TestFilter` then selects a tier without a hand-written attribute. Split a slow suite +into its own project rather than tagging individual tests. Filtering uses TUnit's `--treenode-filter`, not `--filter`: these repositories run on Microsoft.Testing.Platform, where `dotnet test --filter` reports "Zero tests ran". @@ -242,10 +343,11 @@ Microsoft.Testing.Platform, where `dotnet test --filter` reports "Zero tests ran - **A shared fixture.** Move it into `SharedTestingInfra` and make the class public — a shared testing project is a library, so its fixtures are legitimately public API and `CA1515` does not apply. -- **A database or a container.** `Purview.Containers` gives throwaway Linux containers behind one - API, over WSL Containers or Docker. Give the suite its own project so the tier is in the name, and - make it skip itself when no backend is reachable. It is **experimental and prerelease** — pin the - version you adopt. +- **A database or a container.** One `.IntegrationTests` project and one `Purview.Containers` + module. Do not build a fixture framework around it until a second suite needs the same container. +- **A behaviour somebody outside the team asked for.** That, and not before, is when BDTest earns its + place: one `.AcceptanceTests` project with one story. Reach for the HTML report only once + somebody will read it. - **A package with a documented contract.** Add a consumer-verification script; see `purview-engineering-principles`. diff --git a/.agents/skills/purview-testing-conventions/references/acceptance-and-container-tests.md b/.agents/skills/purview-testing-conventions/references/acceptance-and-container-tests.md new file mode 100644 index 0000000..e8b9110 --- /dev/null +++ b/.agents/skills/purview-testing-conventions/references/acceptance-and-container-tests.md @@ -0,0 +1,117 @@ +# Acceptance tests and container-backed tests + +Two shapes that sit outside the unit tier. Both keep TUnit as the runner, and both live in their own +test project so the tier comes from the project name rather than an attribute. + +## Acceptance tests: BDTest + +An acceptance test states a behaviour somebody outside the team asked for, in their words. Use +**BDTest over TUnit through the `BDTest.TUnit` adapter**, in a `.AcceptanceTests` project +(the SDK emits `Category=Acceptance` for it). + +Not Gherkin, SpecFlow or Reqnroll, for two reasons: + +- **BDTest is code-only.** No `.feature` file and no generated test body, so there is no second + artefact that can drift from the step implementing it. That drift is the failure that makes a BDD + suite stop being trusted. +- **One runner stays one runner.** `BDTest.TUnit` is an adapter over TUnit, so the SDK's wiring, the + emitted category and `--treenode-filter` are all unchanged. A BDD framework with its own runner is + exactly what the one-framework rule refuses. + +```csharp +[Story(AsA = "account holder", IWant = "to withdraw money", SoThat = "I can use my savings")] +public class WithdrawalTests : TUnitBDTestBase +{ + AccountSteps Account => new(Context); + + [Test] + [ScenarioText("Withdrawing money reduces the balance")] + public async Task WithdrawMoney() => + await Given(() => Account.CreateAnAccount(100m)) + .When(() => Account.Withdraw(25m)) + .Then(() => Account.TheBalanceIs(75m)) + .BDTestAsync(); +} + +public class AccountSteps(AccountContext context) +{ + [StepText("I have an account with a balance of {0}")] + public void CreateAnAccount(decimal balance) => context.Balance = balance; + + [StepText("I withdraw {0}")] + public void Withdraw(decimal amount) => context.Balance -= amount; + + [StepText("the account balance is {0}")] + public async Task TheBalanceIs(decimal expected) => await Assert.That(context.Balance).IsEqualTo(expected); +} +``` + +Five rules, and the first is the one that bites: + +- **`.BDTestAsync()` runs the scenario.** A chain stopping at `.Then(...)` compiles, passes, and + executes none of its steps — the same failure mode as an un-awaited `Assert.That`. +- **Given/When/Then replaces Arrange/Act/Assert, and only here.** The chain already names the three + phases. Everywhere else the AAA comments stay. +- **`Context` is the only state.** Steps take it through the constructor and hold no fields of their + own; BDTest gives each test its own context, which is what keeps parallel and repeated runs honest. +- **`[StepText]` is written for the report's reader.** `{0}` is replaced with the argument's actual + value at run time, so the report states what the scenario did, not what it was called. +- **Domain content still comes from `TestBuilder`.** The vocabulary changes at the step; the test-data + rule does not. + +Assertions inside a step are ordinary awaited TUnit assertions. `BDTest.Reporting` produces the +single-file HTML report — publish it as a build artifact from the job that ran the suite. + +## A test that needs a real dependency: `Purview.Containers` + +A database, queue, blob store or emulator comes from `Purview.Containers`: a throwaway Linux container +behind one API over Microsoft WSL Containers or Docker, with typed modules for SQL Server, PostgreSQL, +Redis, MySQL, RabbitMQ, Azurite and NATS. + +The two refused alternatives: + +- **A shared instance** (a local SQL Server, a team database) makes tests order-dependent and failures + unreproducible — the state a test found was left by whatever ran before it, on whatever machine. +- **Testcontainers directly** ties the suite to a Docker installation, which on Windows is an install + and a licensing question per developer and per runner. The WSL backend removes it, and the same test + code runs on either. + +```csharp +await using var postgres = new PostgreSqlBuilder() + .WithDatabase("tests") + .WithUsername("postgres") + .WithPassword("postgres") + .Build(); + +await postgres.StartAsync(); + +await using var connection = new NpgsqlConnection(postgres.GetConnectionString()); +await connection.OpenAsync(); +``` + +`Build()` validates the configuration, `StartAsync()` returns only once every wait strategy is +satisfied — there is no sleep to tune — and `await using` disposes the container at end of scope. + +Three rules: + +- **Its own `.IntegrationTests` project**, so the tier is in the name and the suite stays out + of the pull-request filter without anybody remembering an attribute. +- **Skip locally when no backend is usable; fail in CI.** Two different situations, so never one + outcome. Locally, probe and skip. In CI, pin the backend with `PURVIEW_CONTAINERS_BACKEND` — a named + backend never falls back, so a missing runtime fails loudly instead of letting a green job mean + nothing ran: + + ```csharp + [Before(Class)] + public static async Task EnsureABackendIsAvailable() + { + var backends = await ContainerBackends.ProbeAllAsync(); + + Skip.Unless(backends.Any(backend => backend.IsUsable), "No container backend is available."); + } + ``` + + Left unpinned, automatic selection probes in priority order and prefers WSL Containers over Docker. +- **Pin the version exactly.** `Purview.Containers` is **experimental and prerelease** — API, defaults + and packaging rules can change between releases. The packages are a family, so one property in + `Directory.Packages.props` governs all of them and a bump cannot be applied to half. diff --git a/.config/purview-build.json b/.config/purview-build.json index 7becf17..d359f0c 100644 --- a/.config/purview-build.json +++ b/.config/purview-build.json @@ -73,6 +73,12 @@ "README.md", "purview-logo-light.png" ], + "purview.containers.azureservicebusemulator": [ + "lib/$(TFM)/Purview.Containers.AzureServiceBusEmulator.dll", + "lib/$(TFM)/Purview.Containers.AzureServiceBusEmulator.xml", + "README.md", + "purview-logo-light.png" + ], "purview.containers.azurite": [ "lib/$(TFM)/Purview.Containers.Azurite.dll", "lib/$(TFM)/Purview.Containers.Azurite.xml", diff --git a/.github/workflows/integration-wsl.yml b/.github/workflows/integration-wsl.yml index 0a63977..21df9d3 100644 --- a/.github/workflows/integration-wsl.yml +++ b/.github/workflows/integration-wsl.yml @@ -99,6 +99,7 @@ jobs: run: | $projects = @( 'src/tests/AzureKeyVaultEmulator.IntegrationTests/AzureKeyVaultEmulator.IntegrationTests.csproj', + 'src/tests/AzureServiceBusEmulator.IntegrationTests/AzureServiceBusEmulator.IntegrationTests.csproj', 'src/tests/PostgreSql.IntegrationTests/PostgreSql.IntegrationTests.csproj', 'src/tests/Redis.IntegrationTests/Redis.IntegrationTests.csproj', 'src/tests/MsSql.IntegrationTests/MsSql.IntegrationTests.csproj', @@ -171,6 +172,7 @@ jobs: run: | $projects = @( 'src/tests/AzureKeyVaultEmulator.IntegrationTests/AzureKeyVaultEmulator.IntegrationTests.csproj', + 'src/tests/AzureServiceBusEmulator.IntegrationTests/AzureServiceBusEmulator.IntegrationTests.csproj', 'src/tests/PostgreSql.IntegrationTests/PostgreSql.IntegrationTests.csproj', 'src/tests/Redis.IntegrationTests/Redis.IntegrationTests.csproj', 'src/tests/MsSql.IntegrationTests/MsSql.IntegrationTests.csproj', diff --git a/AGENTS.md b/AGENTS.md index 058c2a6..e7293ab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,8 @@ This repository contains `Purview.Containers`, a Testcontainers-style library for .NET that runs throwaway Linux containers for integration testing on **Microsoft WSL Containers (WSLC)** — the Windows runtime with no Docker installation — or on **Docker** through Testcontainers, plus the service modules (`PostgreSql`, -`Redis`, `MsSql`, `RabbitMq`, `Azurite`, `Nats`, `MySql`, `AzureKeyVaultEmulator`). +`Redis`, `MsSql`, `RabbitMq`, `Azurite`, `Nats`, `MySql`, `AzureKeyVaultEmulator`, +`AzureServiceBusEmulator`). `docs/wiki/Backends.md` is the consumer guide to choosing and configuring a backend; keep it up to date whenever a target framework, a backend package or the `buildTransitive` assets change. @@ -188,8 +189,12 @@ Commit messages follow Conventional Commits enforced by the `commit-msg` lefthoo - `package.json` is the authoritative release and package version. Do not diverge project versions by hand. - `.github/workflows/pr.yml` builds and tests pull requests through the shared `purview-dev/build/.github/workflows/purview-build.yml`, with pack and validation enabled. -- `.github/workflows/release.yml` runs the shared release pipeline with `release-mode: NuGet` on a push to - `main`. +- `.github/workflows/release.yml` runs the shared release pipeline with `release-mode: NuGet` on a push to a + `release/**` branch (or a manual dispatch), **not** on a push to `main`. Cut a release by merging the + version bump into `main`, then pushing `release/.` from it. `.config/purview-build.json` + sets the `TrunkReservesMinor` eligibility policy; a version whose `v` tag already exists is + skipped (`REL002`), so every release needs a new `package.json` version. See + [Release Flow](docs/wiki/Release-Flow.md). - `.github/workflows/integration-wsl.yml` runs the WSLC integration suites. The fast contract suite runs on a hosted `windows-2025` runner on every pull request from `pr.yml`; the full service-module matrix runs nightly on `windows-2025` and can be dispatched manually against either the hosted runner or a diff --git a/Directory.Packages.props b/Directory.Packages.props index 64bca29..1c97c54 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -58,6 +58,9 @@ + + + diff --git a/README.md b/README.md index f542604..8ee6a3f 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,8 @@ The WSLC backend is built directly against the `Microsoft.WSL.Containers` NuGet > [Consumer Requirements](docs/wiki/Consumer-Requirements.md) before adopting it. > **Status: preview.** Backend-neutral abstractions, two backends (WSL Containers and Docker), wait -> strategies, the `Image`/`Tag` parser, registry auth, observability, hardening, and **eight service -> modules** (PostgreSQL, Redis, SQL Server, RabbitMQ, Azurite, NATS, MySQL, Azure Key Vault Emulator). On WSLC, **images are shared +> strategies, the `Image`/`Tag` parser, registry auth, observability, hardening, and **nine service +> modules** (PostgreSQL, Redis, SQL Server, RabbitMQ, Azurite, NATS, MySQL, Azure Key Vault Emulator, Azure Service Bus Emulator). On WSLC, **images are shared > by default** (`StorageMode.Shared`): sessions reuse a stable image store > (`%LOCALAPPDATA%\Purview\WslContainers\images`) so images are pulled once, not per session; > `StorageMode.PerSession` provides isolation. Move the store with `PURVIEW_CONTAINERS_STORAGE_PATH` or @@ -147,7 +147,8 @@ src/ Docker/ Docker backend (Purview.Containers.Docker) Containers/ umbrella package (Purview.Containers): Core + both backends PostgreSql/ Redis/ MsSql/ MySql/ - RabbitMq/ Azurite/ Nats/ AzureKeyVaultEmulator/ service modules + RabbitMq/ Azurite/ Nats/ AzureKeyVaultEmulator/ + AzureServiceBusEmulator/ service modules tests/ TUnit unit + integration projects (WSLC and Docker suites) samples/ getting-started/ runnable samples (WslSample, DockerSample, AutoSample) @@ -177,7 +178,7 @@ The project documentation lives in [`docs/wiki`](docs/wiki/Home.md) and is publi Every package also ships its own `README.md` (from `src/src//Sdk/README.md`), so `dotnet add package Purview.Containers.` brings documentation specific to that package. -All eight service modules work today: +All nine service modules work today: ```csharp await using var postgres = new PostgreSqlBuilder() @@ -230,7 +231,7 @@ await using var azurite = new AzuriteBuilder().Build(); await azurite.StartAsync(); // waits for the blob/queue/table listeners -// Supply the Azurite devstoreaccount1 key in AzuriteAccount.Key for authenticated operations. +// Carries the well-known devstoreaccount1 key, so the Azure Storage clients authenticate as-is. string connectionString = azurite.GetConnectionString(); Uri blob = azurite.GetBlobEndpoint(); ``` @@ -245,6 +246,17 @@ var secrets = keyVault.GetSecretClient(); await secrets.SetSecretAsync("mySecret", "myValue"); ``` +```csharp +await using var serviceBus = new AzureServiceBusEmulatorBuilder() + .AcceptLicense() // explicit: the emulator and SQL Server EULAs + .Build(); + +await serviceBus.StartAsync(); // starts its SQL Server sidecar, then waits for GET /health + +await using var client = new ServiceBusClient(serviceBus.GetConnectionString()); +await client.CreateSender("queue.1").SendMessageAsync(new ServiceBusMessage("hello")); +``` + ## Running the tests Every test process owns a single shared WSLC session and, by default, uses the shared image store diff --git a/docs/wiki/Backends.md b/docs/wiki/Backends.md index 1a983d5..56b7558 100644 --- a/docs/wiki/Backends.md +++ b/docs/wiki/Backends.md @@ -2,7 +2,7 @@ `Purview.Containers` has **one API and two interchangeable backends**. The same test code, the same service modules (`Purview.Containers.PostgreSql`, `Redis`, `MsSql`, `RabbitMq`, `Azurite`, `Nats`, `MySql`, -`AzureKeyVaultEmulator`), and +`AzureKeyVaultEmulator`, `AzureServiceBusEmulator`), and the same connection-string accessors run on either runtime — you choose which one by the package you reference, and can override it in code or from the environment. diff --git a/docs/wiki/Connection-Strings.md b/docs/wiki/Connection-Strings.md index e776ead..ca58216 100644 --- a/docs/wiki/Connection-Strings.md +++ b/docs/wiki/Connection-Strings.md @@ -20,10 +20,10 @@ string same = container.GetConnectionString(ConnectionMode.Host); // explici | `Host` | Test host → container, using the mapped host port (`127.0.0.1:{port}`). | ✅ both backends | | `Container` | Container → container, using the container network. | ❌ not supported yet | -`ConnectionMode.Container` throws `ConnectionStringModeNotSupportedException`. Neither backend supports -container-to-container networking today: WSLC has no managed networks or inter-container DNS, and the -Docker backend does not attach containers to a shared network. Multi-container wiring is deliberately -deferred (see [Networking](Networking.md)). +`ConnectionMode.Container` throws `ConnectionStringModeNotSupportedException`. WSLC has no managed networks +or inter-container DNS, and the Docker backend does not attach containers to a shared network, so no +module builds a container-to-container connection string. Containers can still reach each other by +bridge IP (`IContainer.NetworkIp`) — see [Networking](Networking.md). ## How it works diff --git a/docs/wiki/Consumer-Requirements.md b/docs/wiki/Consumer-Requirements.md index 14e0588..867c83a 100644 --- a/docs/wiki/Consumer-Requirements.md +++ b/docs/wiki/Consumer-Requirements.md @@ -124,7 +124,7 @@ transitive references**, so a project that references the backend package direct project that does — gets them automatically. > A **service module** (`Purview.Containers.PostgreSql`, `Redis`, `MsSql`, `RabbitMq`, `Azurite`, `Nats`, -> `MySql`, `AzureKeyVaultEmulator`) is backend-neutral and does **not** bring a backend, so reference the module **and** +> `MySql`, `AzureKeyVaultEmulator`, `AzureServiceBusEmulator`) is backend-neutral and does **not** bring a backend, so reference the module **and** > `Purview.Containers.Wsl` (or another backend package) to run it. Use `Purview.Containers.Docker` to run > the same module on Docker, and `PURVIEW_CONTAINERS_BACKEND` to choose between them. diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index 0cba0c9..16b41ef 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -34,12 +34,13 @@ This wiki is the project documentation hub. The packages are published under the | `Purview.Containers.Docker` | The Docker backend: the same containers on any reachable Docker daemon, driven by Testcontainers. | | `Purview.Containers.PostgreSql` | PostgreSQL container (`postgres:17`), `pg_isready` readiness, Npgsql connection string. | | `Purview.Containers.Redis` | Redis-compatible container (`redis:7`), `redis-cli ping` readiness; also usable with Valkey/Garnet. | -| `Purview.Containers.MsSql` | SQL Server container (`mssql/server:2022-latest`), host-side `SqlClient` readiness, explicit EULA acceptance. | +| `Purview.Containers.MsSql` | SQL Server container (`mssql/server:2025-latest`), host-side `SqlClient` readiness, explicit EULA acceptance. | | `Purview.Containers.RabbitMq` | RabbitMQ container (`rabbitmq:3-management`), AMQP + management endpoints. | | `Purview.Containers.Azurite` | Azure Storage emulator (`azure-storage/azurite`), blob/queue/table endpoints. | | `Purview.Containers.Nats` | NATS broker (`nats:2`), client + monitoring endpoints. | | `Purview.Containers.MySql` | MySQL container (`mysql:8`), host-side `MySqlConnector` readiness. | | `Purview.Containers.AzureKeyVaultEmulator` | Azure Key Vault emulator (`jamesgoulddev/azure-keyvault-emulator`), secrets/keys/certificates endpoints and certificate-pinned Azure SDK clients. | +| `Purview.Containers.AzureServiceBusEmulator` | Azure Service Bus emulator (`azure-messaging/servicebus-emulator`) with its SQL Server sidecar, AMQP and management connection strings, explicit EULA acceptance. | ## Feature highlights diff --git a/docs/wiki/Modules.md b/docs/wiki/Modules.md index 6c2caf3..597f0b3 100644 --- a/docs/wiki/Modules.md +++ b/docs/wiki/Modules.md @@ -140,6 +140,7 @@ that leans on the Testcontainers modules ports across unchanged: | Azurite | Azure Storage string: `DefaultEndpointsProtocol=http`, `AccountName`, `AccountKey`, `Blob/Queue/TableEndpoint` | `GetBlobEndpoint()`, `GetQueueEndpoint()`, `GetTableEndpoint()` | | NATS | `nats://host:port` | `GetClientEndpoint()`, `GetMonitoringEndpoint()` | | Azure Key Vault Emulator | Vault URI: `https://127.0.0.1:{port}` | `GetVaultUri()`, `GetCertificate()`, `GetSecretClient()`, `GetKeyClient()`, `GetCertificateClient()` | +| Azure Service Bus Emulator | Service Bus string: `Endpoint=sb://127.0.0.1:{amqpPort}`, `SharedAccessKeyName`, `SharedAccessKey`, `UseDevelopmentEmulator=true` | `GetManagementConnectionString()` (for `ServiceBusAdministrationClient`), `SqlServer` | ## Module status @@ -147,12 +148,13 @@ that leans on the Testcontainers modules ports across unchanged: |---|---|---|---|---| | PostgreSQL | `postgres:17` | `pg_isready` | Npgsql | ✅ implemented | | Redis | `redis:7` | `redis-cli ping` | StackExchange.Redis | ✅ implemented — also usable with Valkey/Garnet via `WithImage` | -| SQL Server | `mcr.microsoft.com/mssql/server:2022-latest` | host `SqlClient` connection | Microsoft.Data.SqlClient | ✅ implemented — requires `.AcceptLicense()`; the connection string defaults to `Database=master` (`WithDatabase(...)` to change it); session needs ≥ 2000 MB memory | +| SQL Server | `mcr.microsoft.com/mssql/server:2025-latest` | host `SqlClient` connection | Microsoft.Data.SqlClient | ✅ implemented — requires `.AcceptLicense()`; the connection string defaults to `Database=master` (`WithDatabase(...)` to change it); session needs ≥ 2000 MB memory | | RabbitMQ | `rabbitmq:3-management` | log `"Server startup complete"` | RabbitMQ.Client | ✅ implemented — AMQP + management endpoints | -| Azurite | `mcr.microsoft.com/azure-storage/azurite` | log `"successfully listening"` | Azure.Storage.* | ✅ implemented — blob/queue/table endpoints; the well-known `devstoreaccount1` key is a placeholder in `AzuriteAccount.Key` until the consuming repo supplies it | +| Azurite | `mcr.microsoft.com/azure-storage/azurite` | log `"successfully listening"` | Azure.Storage.* | ✅ implemented — blob/queue/table endpoints; the connection string carries the well-known `devstoreaccount1` key (`AzuriteAccount.Key`) | | NATS | `nats:2` | log `"Listening for client connections"` | NATS.Client.Core | ✅ implemented — client + monitoring endpoints | | MySQL | `mysql:8` | host `MySqlConnector` connection | MySqlConnector | ✅ implemented — uses a real connection poll (the image logs `"ready for connections"` during its temporary init server) | | Azure Key Vault Emulator | `jamesgoulddev/azure-keyvault-emulator:3.1.3` (`3.1.3-arm` on ARM64) | HTTPS `GET /` | Azure.Security.KeyVault.* | ✅ implemented — secrets/keys/certificates clients that pin the emulator certificate; a self-signed certificate is generated (and by default installed into the host trust store) and mounted at `/certs`; persistence requires a fixed host port | +| Azure Service Bus Emulator | `mcr.microsoft.com/azure-messaging/servicebus-emulator:latest` | HTTP `GET /health` on 5300 | Azure.Messaging.ServiceBus | ✅ implemented — requires `.AcceptLicense()` (emulator and SQL Server EULAs); starts a SQL Server 2025 sidecar first (`WithSqlServerImage` to change it) and reaches it by bridge IP (`IContainer.NetworkIp`); entities from `WithConfig`/`WithConfigFile` or the image default; session needs ≥ 2000 MB memory for the sidecar | Note on RabbitMQ readiness: the default wait uses the canonical **`Server startup complete`** log signal rather than `rabbitmq-diagnostics ping` — under WSLC the exec-based probe races with the Erlang cookie setup and can trigger a startup failure (`eacces` reading `.erlang.cookie`). WSLC auto-provisions image `VOLUME` declarations on an ext4 device by default; bind-mounting a Windows directory onto one replaces it with drvfs, which ignores Unix `chown` and breaks permission-sensitive images — do not bind-mount onto image volumes unless you intend that. diff --git a/docs/wiki/Networking.md b/docs/wiki/Networking.md index 0265ba6..fcf4b6f 100644 --- a/docs/wiki/Networking.md +++ b/docs/wiki/Networking.md @@ -20,9 +20,9 @@ WSLC networking behaviour and how `Purview.Containers` models it. 1. **Default `NetworkingMode = Bridged`** for containers that bind ports (or, by default, for all containers to be useful). This is baked into `ContainerBuilder` defaults. 2. `GetMappedPublicPort(containerPort)` reads `Inspect().Ports["/tcp"][0].HostPort`. -3. `GetNetworkIp()` reads `Inspect().NetworkSettings.Networks.bridge.IPAddress` (Bridged only). +3. `IContainer.NetworkIp` is the container's bridge IP after start: WSLC reads `Inspect().NetworkSettings.Networks.bridge.IPAddress` (Bridged only), and Docker reports the Testcontainers `IpAddress` (the default bridge). It is `null` before start or without a network. 4. TCP/HTTP wait strategies connect to `127.0.0.1:` (IPv4 only). -5. **Multi-container environments are deliberately deferred.** Because there is no native DNS/alias, a future `ContainerEnvironmentBuilder` would wire containers together by passing each other's IPs (from `GetNetworkIp()`) into configuration/env. The core API must not preclude this (it doesn't — `GetNetworkIp` + environment are enough). +5. **Containers are wired together by IP.** Because there is no native DNS/alias, a dependent container is given its dependency's `NetworkIp` through configuration or environment. A module that owns a dependency starts it first and overrides `ContainerBase.ConfigureForStart` to inject the address, as the Azure Service Bus Emulator does with its SQL Server sidecar (`SQL_SERVER=`). A general `ContainerEnvironmentBuilder` is still deferred. ## What NOT to model diff --git a/docs/wiki/Release-Flow.md b/docs/wiki/Release-Flow.md index bf0eb49..bbb3db1 100644 --- a/docs/wiki/Release-Flow.md +++ b/docs/wiki/Release-Flow.md @@ -4,7 +4,7 @@ How this repository builds, versions, validates and publishes `Purview.Container ## Versioning -`package.json` at the repository root is the authoritative version (`1.0.0-prerelease.1` today). The SDK +`package.json` at the repository root is the authoritative version. The SDK applies that value to `Version` and `PackageVersion` for every project, so a release is a `package.json` bump — never a manual project-file edit. `package.json` also carries the repository, homepage and issue URLs that end up in each nuspec. @@ -50,11 +50,46 @@ the run with the failing module's output. - `.github/workflows/pr.yml` — pull requests build and test via the shared `purview-dev/build/.github/workflows/purview-build.yml`, with pack and validation enabled. -- `.github/workflows/release.yml` — a push to `main` calls `purview-dev/build/.github/workflows/purview-release.yml` - with `release-mode: NuGet`, which packs, publishes to NuGet and creates the `v` GitHub release. +- `.github/workflows/release.yml` — a push to a **`release/**` branch** (or a manual `workflow_dispatch`) + calls `purview-dev/build/.github/workflows/purview-release.yml` with `release-mode: NuGet`. It evaluates + release eligibility and, when eligible, builds, tests, packs, publishes to NuGet and creates the + `v` tag and GitHub release. A push to `main` does **not** release. -Both workflows pin `dotnet-version` to the SDK in `global.json` (`11.0.100-rc.1.26425.128`); keep them in -sync when the SDK is bumped, and keep `purview-build.json` pointing at `src/Containers.slnx`. +Both workflows pin `dotnet-version` to the SDK in `global.json`; keep them in sync when the SDK is bumped, +and keep `purview-build.json` pointing at `src/Containers.slnx`. + +## Cutting a release + +1. Bump `version` in `package.json` on a branch (keep the `-prerelease.N` suffix), and merge it into + `main` through a pull request. +2. Push a release branch for that version's `.` line from `main`, for example: + + ```bash + git switch main && git pull + git push origin main:refs/heads/release/1.1 + ``` + +3. The Release workflow runs on that push. Further fixes for the same line are released by bumping + `package.json` again and pushing the `release/.` branch. + +Check the decision locally before pushing with `dotnet tool run purview-build -- release-explain`. It runs +nothing and publishes nothing; it reports the verdict and the rule that decided it. + +### Eligibility + +`.config/purview-build.json` selects the **`TrunkReservesMinor`** policy: `main` is the trunk, and +`release/*` branches are the stable and servicing refs that publish. The tool evaluates these rules: + +| Rule | Checks | +| --- | --- | +| `REL001` | The version is valid for NuGet. | +| `REL002` | No `v` tag exists yet. A version that has already been released is **skipped**, so every release needs a new `package.json` version. | +| `REL003` | Constrains stable (non-prerelease) versions; prereleases pass. | +| `REL004` | Constrains versions with a non-zero PATCH component. | +| `REL005` | The version is consistent with what already exists on its `.` line. | + +The workflow fails on a `Fail` verdict, logs a notice and publishes nothing on `Skip`, and publishes only +on `Release`. The shared workflow runs on **`ubuntu-latest`**, so the Linux agent builds the portable `net10.0` projects and the `net11.0-windows10.0.19041.0` test projects. That only works because `src/Directory.Build.props` diff --git a/docs/wiki/Testing.md b/docs/wiki/Testing.md index 7b9e5c5..f49c855 100644 --- a/docs/wiki/Testing.md +++ b/docs/wiki/Testing.md @@ -45,7 +45,7 @@ just test '/*/*/*/*[Category=Integration]' --max-parallel-test-modules 1 Integration suites target whichever backend is selected. Automatic detection prefers WSLC and falls back to Docker, so a WSLC host runs the WSLC suites and a Docker-only host runs the Docker ones; pin one with `PURVIEW_CONTAINERS_BACKEND=wsl|docker` to fail loudly instead of falling back. The Docker suites -(`Docker.IntegrationTests` for the container contract, `Modules.Docker.IntegrationTests` for the eight +(`Docker.IntegrationTests` for the container contract, `Modules.Docker.IntegrationTests` for the nine service modules) skip themselves when no daemon is reachable, and the WSLC suites skip themselves when the host lacks the WSL Containers components. Set `PURVIEW_CONTAINERS_REQUIRE_BACKEND=wsl|docker` to turn that skip into a failure — the CI integration jobs do. See [Backends: WSLC or Docker](Backends.md) for @@ -53,7 +53,7 @@ consumer-facing setup and CI examples. ```powershell just test src/tests/Docker.IntegrationTests/Docker.IntegrationTests.csproj # Docker contract -just test src/tests/Modules.Docker.IntegrationTests/Modules.Docker.IntegrationTests.csproj # the eight modules on Docker +just test src/tests/Modules.Docker.IntegrationTests/Modules.Docker.IntegrationTests.csproj # the nine modules on Docker ``` ## Why test modules run serially @@ -89,7 +89,7 @@ the workflow adds `C:\Program Files\WSL` and calls it by absolute path. Coverage is split for cost: - **Every pull request**: `pr.yml` runs `Wsl.IntegrationTests` on `windows-2025` (the contract suite). -- **Nightly**: `.github/workflows/integration-wsl.yml` runs the contract suite and the eight service-module +- **Nightly**: `.github/workflows/integration-wsl.yml` runs the contract suite and the nine service-module suites on `windows-2025`, one project at a time. - **On demand**: the same workflow accepts `runner: self-hosted` to use a warm self-hosted Windows runner with the custom `wslc` label, and `scope: contract` to run only the contract suite. diff --git a/package.json b/package.json index 4f7f793..1cb9fb0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "containers", - "version": "1.0.0-prerelease.8", + "version": "1.1.0-prerelease.1", "description": "Testcontainers-style library for .NET that runs throwaway Linux containers for integration testing, on Microsoft WSL Containers or Docker.", "license": "MIT", "author": { diff --git a/skills-lock.json b/skills-lock.json index 3985014..fb6c4df 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -11,7 +11,7 @@ "source": "purview-dev/engineering-playbook", "sourceType": "github", "skillPath": "skills/purview-architecture-testing/SKILL.md", - "computedHash": "e9bc98e7f6eca4160b4632427e5003359edbd69c70857c9b0d863aeb144c1ba1" + "computedHash": "5907c6bf28788b755b752041e2f0cd88222604a949988d487c25940b6284fce4" }, "purview-buildsdk-adoption": { "source": "purview-dev/build-sdk", @@ -113,7 +113,7 @@ "source": "purview-dev/engineering-playbook", "sourceType": "github", "skillPath": "skills/purview-testing-conventions/SKILL.md", - "computedHash": "c04e8ae03a96367d9dc5d0414b960bd389904de3756fe9f333dc8bcdfa3e2f7d" + "computedHash": "388b0a79b565ed45a8513d77e8ed6e3d4491f96b43328cb09933cec7811c10f6" } } } diff --git a/skills/purview-containers-lifecycle/SKILL.md b/skills/purview-containers-lifecycle/SKILL.md index 01b84ed..96da38e 100644 --- a/skills/purview-containers-lifecycle/SKILL.md +++ b/skills/purview-containers-lifecycle/SKILL.md @@ -129,14 +129,17 @@ Verified WSLC behaviour, and the parts that surprise people: create/connect`, unlike the CLI. How the library models it: `GetMappedPublicPort(containerPort)` reads -`Inspect().Ports["/tcp"][0].HostPort`; `GetNetworkIp()` reads -`Inspect().NetworkSettings.Networks.bridge.IPAddress` (Bridged only); TCP and HTTP waits connect to +`Inspect().Ports["/tcp"][0].HostPort`; `IContainer.NetworkIp` is the bridge IP after start +(WSLC reads `Inspect().NetworkSettings.Networks.bridge.IPAddress`, Docker the Testcontainers +`IpAddress`; `null` before start or without a network); TCP and HTTP waits connect to `127.0.0.1:`. **What not to model.** Do not invent Docker-style `Network` objects — the semantics are just "bridged -with IPs". Do not claim container-name resolution works; it does not. Multi-container environments -are deliberately deferred, and would wire containers together by passing each other's IPs from -`GetNetworkIp()` into configuration or environment — which is why the core API does not preclude it. +with IPs". Do not claim container-name resolution works; it does not. Containers are wired +together by passing a dependency's `NetworkIp` into configuration or environment: a module that owns a +dependency starts it first and injects the address from `ContainerBase.ConfigureForStart` (the Azure +Service Bus Emulator does this with its SQL Server sidecar). A general multi-container environment +builder is still deferred. ## Related skills diff --git a/skills/purview-containers-quickstart/SKILL.md b/skills/purview-containers-quickstart/SKILL.md index c90b6c1..6d439f7 100644 --- a/skills/purview-containers-quickstart/SKILL.md +++ b/skills/purview-containers-quickstart/SKILL.md @@ -129,8 +129,8 @@ await connection.OpenAsync(); No image names, no ports, no `docker`, no `wslc`. The default image, the random host port mapping and the readiness check all come from the module. -Modules: PostgreSQL, Redis, SQL Server, MySQL, RabbitMQ, Azurite, NATS and the Azure Key Vault -Emulator. Each ships a README inside its own package. +Modules: PostgreSQL, Redis, SQL Server, MySQL, RabbitMQ, Azurite, NATS, the Azure Key Vault +Emulator and the Azure Service Bus Emulator. Each ships a README inside its own package. ## Connection strings diff --git a/src/Containers.slnx b/src/Containers.slnx index 184155c..3035886 100644 --- a/src/Containers.slnx +++ b/src/Containers.slnx @@ -11,6 +11,7 @@ + @@ -33,6 +34,8 @@ + + diff --git a/src/src/AzureServiceBusEmulator/AzureServiceBusEmulator.csproj b/src/src/AzureServiceBusEmulator/AzureServiceBusEmulator.csproj new file mode 100644 index 0000000..c3831ae --- /dev/null +++ b/src/src/AzureServiceBusEmulator/AzureServiceBusEmulator.csproj @@ -0,0 +1,16 @@ + + + true + Azure Service Bus Emulator module for Purview.Containers: throwaway Service Bus emulators (queues, topics, subscriptions) with their SQL Server dependency, on WSL Containers or Docker. + wsl;containers;docker;servicebus;azure;emulator;messaging;testing;integration + MIT + Purview + + + + + + + + diff --git a/src/src/AzureServiceBusEmulator/AzureServiceBusEmulatorBuilder.cs b/src/src/AzureServiceBusEmulator/AzureServiceBusEmulatorBuilder.cs new file mode 100644 index 0000000..090bf54 --- /dev/null +++ b/src/src/AzureServiceBusEmulator/AzureServiceBusEmulatorBuilder.cs @@ -0,0 +1,199 @@ +using Purview.Containers.Diagnostics; +using Purview.Containers.MsSql; +using Purview.Containers.Runtime; +using Purview.Containers.Waiting; + +namespace Purview.Containers.AzureServiceBusEmulator; + +/// +/// Fluent builder for an Azure +/// Service Bus Emulator test container. The emulator stores its state in SQL Server, so the container +/// starts a SQL Server sidecar on the same backend first and points the emulator at its bridge address. +/// +public class AzureServiceBusEmulatorBuilder + : ContainerBuilder< + AzureServiceBusEmulatorBuilder, + AzureServiceBusEmulatorContainer, + AzureServiceBusEmulatorConfiguration + > +{ + /// AMQP port the Service Bus clients connect to. + public const ushort AmqpPort = 5672; + + /// HTTP management port: the health endpoint and the administration (entity management) API. + public const ushort ManagementPort = 5300; + + /// Default image. + public const string EmulatorImage = "mcr.microsoft.com/azure-messaging/servicebus-emulator:latest"; + + /// Container directory the emulator reads Config.json from. + public const string ConfigMountPath = "/ServiceBus_Emulator/ConfigFiles"; + + /// Default SA password of the SQL Server sidecar. + public const string DefaultSqlServerPassword = "YourStrong!Passw0rd"; + + /// + /// Default SQL Server sidecar image: the MsSql module's default, SQL Server 2025. SQL Server 2022 does not + /// start on WSLC (it cannot create its system directory as the non-root mssql user). + /// + public const string DefaultSqlServerImage = MsSqlBuilder.MsSqlImage; + + bool _acceptLicense; + string _sqlServerImage = DefaultSqlServerImage; + Secret _sqlServerPassword = Secret.From(DefaultSqlServerPassword); + string? _configJson; + + /// Creates a builder with the default image. + public AzureServiceBusEmulatorBuilder() + : this(EmulatorImage) { } + + /// Creates a builder with a custom image. + public AzureServiceBusEmulatorBuilder(string image) + { + ApplyDefaults(image); + } + + /// Creates a builder using an explicit backend. The SQL Server sidecar runs on it too. + public AzureServiceBusEmulatorBuilder(IContainerBackend backend) + : base(backend) + { + ApplyDefaults(EmulatorImage); + } + + /// + /// Explicitly accepts the Azure Service Bus Emulator EULA and the SQL Server EULA of its sidecar + /// (ACCEPT_EULA=Y on both). Required before Build; the library never accepts licensing terms on the + /// caller's behalf. + /// + public AzureServiceBusEmulatorBuilder AcceptLicense() + { + _acceptLicense = true; + WithEnvironment("ACCEPT_EULA", "Y"); + return this; + } + + /// Sets the SQL Server sidecar image (default ). + public AzureServiceBusEmulatorBuilder WithSqlServerImage(string image) + { + ArgumentException.ThrowIfNullOrWhiteSpace(image); + _sqlServerImage = image; + return this; + } + + /// Sets the SQL Server sidecar SA password. Must satisfy SQL Server password complexity (at least 8 characters). + public AzureServiceBusEmulatorBuilder WithSqlServerPassword(string password) + { + _sqlServerPassword = Secret.From(password); + return this; + } + + /// + /// Uses as the emulator Config.json, declaring the namespace and its + /// queues, topics and subscriptions. Without it the image's default configuration applies. + /// + public AzureServiceBusEmulatorBuilder WithConfig(string json) + { + ArgumentException.ThrowIfNullOrWhiteSpace(json); + _configJson = json; + return this; + } + + /// Reads the emulator Config.json from ; see . + public AzureServiceBusEmulatorBuilder WithConfigFile(string path) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + if (!File.Exists(path)) + { + throw new ContainerConfigurationException( + $"The Service Bus emulator configuration file '{path}' does not exist." + ); + } + + return WithConfig(File.ReadAllText(path)); + } + + /// Builds the immutable configuration (internal; used by the module's own tests). + internal AzureServiceBusEmulatorConfiguration BuildConfigurationForTesting() => BuildConfiguration(); + + /// + protected override AzureServiceBusEmulatorConfiguration BuildConfiguration() + { + var configuration = base.BuildConfiguration(); + Dictionary environment = new(configuration.Environment, StringComparer.Ordinal) + { + ["MSSQL_SA_PASSWORD"] = _sqlServerPassword.Value, + // The sidecar is already accepting connections when the emulator starts, so skip its built-in delay. + ["SQL_WAIT_INTERVAL"] = "0", + }; + if (_acceptLicense) + { + environment["ACCEPT_EULA"] = "Y"; + } + + var waitStrategies = + configuration.WaitStrategies.Count > 0 + ? configuration.WaitStrategies + : new[] { (IWaitStrategy)BuildDefaultWaitStrategy() }; + + return configuration with + { + Environment = environment, + WaitStrategies = waitStrategies, + AcceptLicense = _acceptLicense, + SqlServerImage = _sqlServerImage, + SqlServerPassword = _sqlServerPassword, + ConfigJson = _configJson, + }; + } + + /// + protected override void Validate(ContainerConfiguration configuration) + { + base.Validate(configuration); + if (!_acceptLicense) + { + throw new ContainerConfigurationException( + "The Azure Service Bus Emulator and its SQL Server sidecar require accepting their EULAs. Call AcceptLicense() explicitly before Build()." + ); + } + + if (_sqlServerPassword.Value.Length < 8) + { + throw new ContainerConfigurationException("The SQL Server SA password must be at least 8 characters long."); + } + } + + /// + protected override AzureServiceBusEmulatorContainer CreateContainer( + AzureServiceBusEmulatorConfiguration configuration + ) + { + ArgumentNullException.ThrowIfNull(configuration); + return new(configuration, BuildSqlServer(configuration), Backend); + } + + MsSqlContainer BuildSqlServer(AzureServiceBusEmulatorConfiguration configuration) + { + var builder = Backend is null ? new MsSqlBuilder() : new MsSqlBuilder(Backend); + return builder + .WithImage(configuration.SqlServerImage) + .WithPassword(configuration.SqlServerPassword.Value) + .AcceptLicense() + .WithStartupTimeout(configuration.StartupTimeout) + .Build(); + } + + static WaitStrategy BuildDefaultWaitStrategy() + { + // The emulator's own health endpoint proves the service is up, not merely that a port is listening. + return Wait.ForHttp("/health").ForPort(ManagementPort).WithInterval(TimeSpan.FromSeconds(1)); + } + + void ApplyDefaults(string image) + { + WithImage(image) + .WithPortBinding(AmqpPort, assignRandomHostPort: true) + .WithPortBinding(ManagementPort, assignRandomHostPort: true) + .WithConnectionStringProvider(new AzureServiceBusEmulatorConnectionStringProvider()); + } +} diff --git a/src/src/AzureServiceBusEmulator/AzureServiceBusEmulatorConfiguration.cs b/src/src/AzureServiceBusEmulator/AzureServiceBusEmulatorConfiguration.cs new file mode 100644 index 0000000..a025282 --- /dev/null +++ b/src/src/AzureServiceBusEmulator/AzureServiceBusEmulatorConfiguration.cs @@ -0,0 +1,23 @@ +using Purview.Containers.Diagnostics; + +namespace Purview.Containers.AzureServiceBusEmulator; + +/// Immutable configuration for an Azure Service Bus Emulator test container. +public sealed record AzureServiceBusEmulatorConfiguration : ContainerConfiguration +{ + /// True when the emulator and SQL Server EULAs have been explicitly accepted. + public bool AcceptLicense { get; init; } + + /// Image of the SQL Server sidecar the emulator stores its state in. + public string SqlServerImage { get; init; } = AzureServiceBusEmulatorBuilder.DefaultSqlServerImage; + + /// SA password of the SQL Server sidecar. Rendered as redacted. + public Secret SqlServerPassword { get; init; } = + Secret.From(AzureServiceBusEmulatorBuilder.DefaultSqlServerPassword); + + /// + /// The emulator Config.json (namespaces, queues, topics, subscriptions), or null to use the + /// configuration baked into the image. + /// + public string? ConfigJson { get; init; } +} diff --git a/src/src/AzureServiceBusEmulator/AzureServiceBusEmulatorConnectionStringProvider.cs b/src/src/AzureServiceBusEmulator/AzureServiceBusEmulatorConnectionStringProvider.cs new file mode 100644 index 0000000..cc13e29 --- /dev/null +++ b/src/src/AzureServiceBusEmulator/AzureServiceBusEmulatorConnectionStringProvider.cs @@ -0,0 +1,9 @@ +namespace Purview.Containers.AzureServiceBusEmulator; + +/// Provides the emulator's Service Bus connection string. +sealed class AzureServiceBusEmulatorConnectionStringProvider + : ContainerConnectionStringProvider +{ + /// + protected override string GetHostConnectionString() => Container.GetConnectionString(); +} diff --git a/src/src/AzureServiceBusEmulator/AzureServiceBusEmulatorContainer.cs b/src/src/AzureServiceBusEmulator/AzureServiceBusEmulatorContainer.cs new file mode 100644 index 0000000..f0843b7 --- /dev/null +++ b/src/src/AzureServiceBusEmulator/AzureServiceBusEmulatorContainer.cs @@ -0,0 +1,125 @@ +using Purview.Containers.Mounts; +using Purview.Containers.MsSql; +using Purview.Containers.Runtime; + +namespace Purview.Containers.AzureServiceBusEmulator; + +/// +/// A throwaway Azure Service Bus +/// Emulator instance running on WSL Containers or Docker, together with the SQL Server sidecar it +/// stores its state in. Starting the container starts the sidecar first; disposing it removes both. +/// +public sealed class AzureServiceBusEmulatorContainer : ContainerBase +{ + const string ConfigFileName = "Config.json"; + + // The emulator's fixed development credentials: it accepts only this key name and value. + const string SharedAccessKeyName = "RootManageSharedAccessKey"; + const string SharedAccessKey = "SAS_KEY_VALUE"; + + readonly AzureServiceBusEmulatorConfiguration _configuration; + + int _prepared; + string? _configDirectory; + + internal AzureServiceBusEmulatorContainer( + AzureServiceBusEmulatorConfiguration configuration, + MsSqlContainer sqlServer, + IContainerBackend? backend + ) + : base(configuration, backend) + { + _configuration = configuration; + SqlServer = sqlServer; + } + + /// The SQL Server sidecar the emulator stores its state in (for diagnostics such as its logs). + public MsSqlContainer SqlServer { get; } + + /// + /// Service Bus connection string for the AMQP endpoint, for ServiceBusClient. Safe to call after + /// . + /// + public string GetConnectionString() => BuildConnectionString(AzureServiceBusEmulatorBuilder.AmqpPort); + + /// + /// Service Bus connection string for the management endpoint, for ServiceBusAdministrationClient. + /// Safe to call after . + /// + public string GetManagementConnectionString() => + BuildConnectionString(AzureServiceBusEmulatorBuilder.ManagementPort); + + /// + public override async Task StartAsync(CancellationToken cancellationToken = default) + { + if (Interlocked.Exchange(ref _prepared, 1) == 1) + { + return; + } + + // The emulator connects to SQL Server as soon as it starts, so the sidecar must be accepting + // connections first. There is no container-name DNS, so the emulator reaches it by bridge IP. + await SqlServer.StartAsync(cancellationToken).ConfigureAwait(false); + if (SqlServer.NetworkIp is null) + { + throw new ContainerException( + "The SQL Server sidecar has no bridge-network IP address, so the Service Bus emulator cannot reach it." + ); + } + + if (_configuration.ConfigJson is { } configJson) + { + _configDirectory = Path.Combine(Path.GetTempPath(), "purview-servicebus-emulator", Name); + Directory.CreateDirectory(_configDirectory); + await File.WriteAllTextAsync(Path.Combine(_configDirectory, ConfigFileName), configJson, cancellationToken) + .ConfigureAwait(false); + } + + await base.StartAsync(cancellationToken).ConfigureAwait(false); + } + + /// + public override async ValueTask DisposeAsync() + { + await base.DisposeAsync().ConfigureAwait(false); + await SqlServer.DisposeAsync().ConfigureAwait(false); + + if (_configDirectory is not null && Directory.Exists(_configDirectory)) + { + Directory.Delete(_configDirectory, recursive: true); + } + + _configDirectory = null; + } + + /// + protected override IContainerConfiguration ConfigureForStart(IContainerConfiguration configuration) + { + if (configuration is not ContainerConfiguration concrete) + { + return configuration; + } + + Dictionary environment = new(concrete.Environment, StringComparer.Ordinal) + { + ["SQL_SERVER"] = SqlServer.NetworkIp!, + }; + + List bindMounts = [.. concrete.BindMounts]; + if (_configDirectory is not null) + { + bindMounts.Add( + new BindMount(_configDirectory, AzureServiceBusEmulatorBuilder.ConfigMountPath, ReadOnly: true) + ); + } + + return concrete with + { + Environment = environment, + BindMounts = bindMounts, + }; + } + + string BuildConnectionString(ushort containerPort) => + $"Endpoint=sb://127.0.0.1:{GetMappedPublicPort(containerPort)};SharedAccessKeyName={SharedAccessKeyName};SharedAccessKey={SharedAccessKey};UseDevelopmentEmulator=true;"; +} diff --git a/src/src/AzureServiceBusEmulator/Sdk/README.md b/src/src/AzureServiceBusEmulator/Sdk/README.md new file mode 100644 index 0000000..25b3b81 --- /dev/null +++ b/src/src/AzureServiceBusEmulator/Sdk/README.md @@ -0,0 +1,211 @@ +# Purview.Containers.AzureServiceBusEmulator + +Throwaway [Azure Service Bus Emulator](https://github.com/Azure/azure-service-bus-emulator-installer) +instances for .NET integration testing on **WSL Containers (WSLC)** or **Docker**. The emulator speaks +AMQP, so `ServiceBusClient` from `Azure.Messaging.ServiceBus` works against it unchanged. + +```bash +dotnet add package Purview.Containers.AzureServiceBusEmulator +``` + +Backend-neutral: depends on `Purview.Containers.Core` and `Purview.Containers.MsSql` (the emulator stores +its state in SQL Server), and needs a backend package (`Purview.Containers.Wsl` or +`Purview.Containers.Docker`). + +## Requirements + +- **WSL Containers backend:** Windows 10/11 with WSL Containers (`wsl --install --no-distribution`), and a + `.NET 10` or later project. A platform-neutral `net10.0` project binds the portable facade and gets + automatic WSLC-or-Docker selection; a Windows target framework + (`net10.0-windows10.0.19041.0`, x64 or arm64) binds the implementation directly. The + `Purview.Containers.Wsl` package supplies `buildTransitive` defaults for + `WindowsSdkPackageVersion`/`PlatformTarget` and (for a platform-neutral consumer on a Windows build + host) the implementation payload, rejecting an unsupported consumer with `PCC0001`/`PCC0002` — see the + [consumer requirements](https://github.com/purview-dev/containers/blob/main/docs/wiki/Consumer-Requirements.md). +- **Docker backend:** any reachable Docker daemon (`docker info`), with a `net10.0` or later project on any + platform. No Windows target framework and no `PCC` guards apply. +- **SQL Server sidecar:** the default is SQL Server 2025 (`mcr.microsoft.com/mssql/server:2025-latest`), + which is x64 only and needs at least 2000 MB of memory in the WSLC session VM or Docker host. On ARM64, + `WithSqlServerImage("mcr.microsoft.com/azure-sql-edge:latest")` is an alternative the emulator supports. + SQL Server 2022 does not start on WSLC (it fails to create `/.system` as the non-root `mssql` user). +- **Experimental:** the API, defaults and packaging can change between prereleases; there is no + production support guarantee. +- The emulator is for local development and testing only; it is not a replacement for Azure Service Bus. + +## Quick start + +```csharp +using Azure.Messaging.ServiceBus; +using Purview.Containers.AzureServiceBusEmulator; + +await using var emulator = new AzureServiceBusEmulatorBuilder() + .AcceptLicense() // explicit: the emulator and SQL Server EULAs + .Build(); + +await emulator.StartAsync(); // starts SQL Server, then the emulator; waits for GET /health + +await using ServiceBusClient client = new(emulator.GetConnectionString()); +await client.CreateSender("queue.1").SendMessageAsync(new ServiceBusMessage("hello")); +``` + +Without a configuration the image's default `Config.json` applies (namespace `sbemulatorns`, queue +`queue.1`, topic `topic.1` with subscription `subscription.1`). + +## Licensing + +`Build()` throws until `AcceptLicense()` is called. That single call accepts both the +[emulator EULA](https://github.com/Azure/azure-service-bus-emulator-installer/blob/main/EMULATOR_EULA.txt) +and the SQL Server EULA of the sidecar (`ACCEPT_EULA=Y` on both containers). The library never accepts +licensing terms on your behalf. + +## Entities + +Declare the namespace, queues, topics and subscriptions with the emulator's `Config.json` format: + +```csharp +await using var emulator = new AzureServiceBusEmulatorBuilder() + .AcceptLicense() + .WithConfigFile("servicebus-config.json") // or .WithConfig(json) + .Build(); +``` + +The configuration is written to a per-container temporary directory, mounted read-only at +`/ServiceBus_Emulator/ConfigFiles`, and deleted on dispose. See the +[emulator configuration reference](https://learn.microsoft.com/azure/service-bus-messaging/overview-emulator#configuration) +for the schema and its quotas. + +## How the SQL Server sidecar is wired + +WSL Containers has no container-name DNS, so the module does what both backends support: it starts a SQL +Server container (through the `MsSql` module, so readiness is a real client connection) and passes its +bridge IP (`IContainer.NetworkIp`) to the emulator as `SQL_SERVER`. Disposing the emulator disposes the +sidecar. `SqlServer` exposes the sidecar for diagnostics such as its logs. + +## Sharing one emulator across tests + +Starting an emulator takes roughly a minute (SQL Server, then the emulator), so start **one per test +session** and give every test its own entities. The tests can then run in parallel: a test only ever +reads from the queue or topic it created, so it never sees another test's messages. + +Changing the database name does not help instead. The emulator always uses the same two databases +(`SbGatewayDatabase`, `SbMessageContainerDatabase00001`) and drops and recreates them on every start, so +two emulators pointed at one SQL Server would wipe each other. + +With TUnit, share the emulator through a `ClassDataSource` fixture: + +```csharp +using Azure.Messaging.ServiceBus; +using Azure.Messaging.ServiceBus.Administration; +using Purview.Containers.AzureServiceBusEmulator; +using TUnit.Core.Interfaces; + +public sealed class ServiceBusEmulatorFixture : IAsyncInitializer, IAsyncDisposable +{ + public AzureServiceBusEmulatorContainer Emulator { get; } = + new AzureServiceBusEmulatorBuilder().AcceptLicense().Build(); + + public ServiceBusClient Client { get; private set; } = default!; + + public ServiceBusAdministrationClient Administration { get; private set; } = default!; + + public async Task InitializeAsync() + { + await Emulator.StartAsync(); + Client = new ServiceBusClient(Emulator.GetConnectionString()); + Administration = new ServiceBusAdministrationClient(Emulator.GetManagementConnectionString()); + } + + // A unique queue per test; delete it afterwards to stay under the namespace's entity quota. + public async Task CreateQueueAsync() + { + var name = $"q-{Guid.NewGuid():N}"; + await Administration.CreateQueueAsync(name); + return name; + } + + public async ValueTask DisposeAsync() + { + await Client.DisposeAsync(); + await Emulator.DisposeAsync(); + } +} + +[ClassDataSource(Shared = SharedType.PerTestSession)] +public class OrderProcessingTests(ServiceBusEmulatorFixture serviceBus) +{ + [Test] + public async Task PlacedOrder_IsPublished() + { + var queue = await serviceBus.CreateQueueAsync(); + try + { + await using var sender = serviceBus.Client.CreateSender(queue); + await sender.SendMessageAsync(new ServiceBusMessage("order-1")); + + await using var receiver = serviceBus.Client.CreateReceiver(queue); + var message = await receiver.ReceiveMessageAsync(TimeSpan.FromSeconds(30)); + + await Assert.That(message!.Body.ToString()).IsEqualTo("order-1"); + } + finally + { + await serviceBus.Administration.DeleteQueueAsync(queue); + } + } +} +``` + +Topics work the same way: `CreateTopicAsync(name)` followed by `CreateSubscriptionAsync(name, subscription)`. +Create entities at run time rather than in `Config.json` when tests share an instance; the configuration +is fixed when the emulator starts. With xUnit, put the same start/stop code in an assembly or collection +fixture. + +## Limitations + +What this module and its tests have established: + +- **One SQL Server per emulator.** The fixed database names (see above) mean an emulator owns its SQL + Server outright. Share the emulator, never the database server. +- **No state survives a restart.** The databases are recreated on every start, so entities created at run + time and any unconsumed messages are lost with the container. +- **Slow, memory-hungry start.** About a minute per instance, and the SQL Server sidecar needs at least + 2000 MB. Running several emulators at once multiplies both, which is why sharing one is recommended. +- **x64 only by default.** SQL Server 2025 has no ARM64 image. +- **Host connections only.** The connection strings point at `127.0.0.1` and the mapped host ports. An + application under test that runs in another container cannot use them (`ConnectionMode.Container` is + not supported). +- **Fixed development credentials.** The emulator accepts only `RootManageSharedAccessKey` / + `SAS_KEY_VALUE`; there is no Microsoft Entra ID (token) authentication. +- **Unpinned image.** The default emulator tag is `latest`, so an upstream release can change behaviour. + Pin a tag with `WithImage(...)` when a test run must be reproducible. +- **Its logs contain the SQL Server password.** The emulator writes its SQL connection string, password + included, to its log. Treat those logs as sensitive. + +The emulator is also not the full service. Check Microsoft's +[emulator documentation](https://learn.microsoft.com/azure/service-bus-messaging/overview-emulator) for its +current quotas (such as the number of entities per namespace and the maximum message size) and the +features it does not support, before relying on it for a given scenario. + +## API + +| Member | Purpose | +| -- | -- | +| `AzureServiceBusEmulatorBuilder()` / `(string image)` / `(IContainerBackend)` | Default image `mcr.microsoft.com/azure-messaging/servicebus-emulator:latest`, or a custom image. The sidecar uses the same backend. | +| `AzureServiceBusEmulatorBuilder.AmqpPort` (5672), `ManagementPort` (5300) | Container ports; both are mapped to random host ports. | +| `AcceptLicense()` | Required: accepts the emulator and SQL Server EULAs. | +| `WithConfig(string json)` / `WithConfigFile(string path)` | The emulator `Config.json`. | +| `WithSqlServerImage(string)` / `WithSqlServerPassword(string)` | Sidecar image (default `mcr.microsoft.com/mssql/server:2025-latest`) and SA password (at least 8 characters). | +| `AzureServiceBusEmulatorContainer.GetConnectionString()` | `Endpoint=sb://127.0.0.1:{amqpPort};SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true;` for `ServiceBusClient`. | +| `AzureServiceBusEmulatorContainer.GetManagementConnectionString()` | The same against the management port, for `ServiceBusAdministrationClient`. | +| `AzureServiceBusEmulatorContainer.SqlServer` | The SQL Server sidecar container. | + +The default wait strategy polls `GET /health` on the management port and is replaced when you supply +your own with `WithWaitStrategy(...)`. Call the accessors after `StartAsync()`, once the mapped ports are +known. + +## Documentation + +- [Backends: WSLC or Docker](https://github.com/purview-dev/containers/blob/main/docs/wiki/Backends.md) — choosing and configuring the runtime. +- [Modules](https://github.com/purview-dev/containers/blob/main/docs/wiki/Modules.md) — the module contract and readiness choices. +- [Networking](https://github.com/purview-dev/containers/blob/main/docs/wiki/Networking.md) — how containers reach each other by IP. +- [Azure Service Bus Emulator](https://learn.microsoft.com/azure/service-bus-messaging/overview-emulator) — the emulator itself. diff --git a/src/src/Azurite/AzuriteAccount.cs b/src/src/Azurite/AzuriteAccount.cs index f1d9c34..ace9cfc 100644 --- a/src/src/Azurite/AzuriteAccount.cs +++ b/src/src/Azurite/AzuriteAccount.cs @@ -6,8 +6,11 @@ public static class AzuriteAccount /// The well-known Azurite storage account name. public const string Name = "devstoreaccount1"; - // The well-known Azurite devstoreaccount1 key is intentionally NOT embedded in this library. - // The consuming repository supplies it here (or via a runtime configuration point) before - // authenticated operations are exercised. - public const string Key = ""; + /// + /// The well-known Azurite devstoreaccount1 key. It is a published constant of the emulator (the + /// same value the Azure Storage Emulator used), not a credential: it only ever authenticates against a + /// local emulator. + /// + public const string Key = + "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw=="; } diff --git a/src/src/Azurite/AzuriteContainer.cs b/src/src/Azurite/AzuriteContainer.cs index a3f22ef..032eb84 100644 --- a/src/src/Azurite/AzuriteContainer.cs +++ b/src/src/Azurite/AzuriteContainer.cs @@ -27,8 +27,8 @@ public Uri GetTableEndpoint() return new Uri($"http://127.0.0.1:{GetMappedPublicPort(AzuriteBuilder.TablePort)}/{AzuriteAccount.Name}"); } - /// Azure Storage connection string for the emulator. The account key comes from - /// (a placeholder until the consuming repository supplies it). + /// Azure Storage connection string for the emulator, authenticated with the well-known + /// . public string GetConnectionString() { StringBuilder builder = new(); diff --git a/src/src/Azurite/Sdk/README.md b/src/src/Azurite/Sdk/README.md index df24de4..3ae026c 100644 --- a/src/src/Azurite/Sdk/README.md +++ b/src/src/Azurite/Sdk/README.md @@ -46,7 +46,7 @@ string connectionString = azurite.GetConnectionString(); | `AzuriteBuilder.BlobPort` (10000), `QueuePort` (10001), `TablePort` (10002) | Container service ports; all three are mapped to random host ports. | | `AzuriteContainer.GetBlobEndpoint()`, `GetQueueEndpoint()`, `GetTableEndpoint()` | Service endpoints (`http://127.0.0.1:{mappedPort}/devstoreaccount1`). | | `AzuriteContainer.GetConnectionString()` | Azure Storage connection string for the emulator. | -| `AzuriteAccount.Name` | The well-known account name `devstoreaccount1`. | +| `AzuriteAccount.Name` / `AzuriteAccount.Key` | The well-known account name `devstoreaccount1` and its published key. | The container starts Azurite with `--blobHost 0.0.0.0 --queueHost 0.0.0.0 --tableHost 0.0.0.0` and waits for the `successfully listening` log signal. Endpoints and the connection string are only meaningful after @@ -54,8 +54,14 @@ for the `successfully listening` log signal. Endpoints and the connection string ## Account key -The well-known `devstoreaccount1` key is a published constant of the emulator, but this library does not -embed it: `AzuriteAccount.Key` holds a placeholder. Supply the real key in your test infrastructure before -exercising authenticated operations (anonymous/local development paths are unaffected when the client does -not require the key). See [Modules](https://github.com/purview-dev/containers/blob/main/docs/wiki/Modules.md) -for the module contract. +`AzuriteAccount.Key` is the well-known `devstoreaccount1` key, a published constant of the emulator, and +`GetConnectionString()` includes it. The Azure Storage clients therefore authenticate against the +container without further setup: + +```csharp +BlobServiceClient blobs = new(azurite.GetConnectionString()); +await blobs.CreateBlobContainerAsync("my-container"); +``` + +See [Modules](https://github.com/purview-dev/containers/blob/main/docs/wiki/Modules.md) for the module +contract. diff --git a/src/src/Core/ContainerBase.cs b/src/src/Core/ContainerBase.cs index a02efa4..6de0aca 100644 --- a/src/src/Core/ContainerBase.cs +++ b/src/src/Core/ContainerBase.cs @@ -52,6 +52,9 @@ protected ContainerBase(IContainerConfiguration configuration, IContainerBackend /// public string Image => Configuration.Image; + /// + public string? NetworkIp => _container?.NetworkIp; + /// public virtual async Task StartAsync(CancellationToken cancellationToken = default) { @@ -62,7 +65,7 @@ public virtual async Task StartAsync(CancellationToken cancellationToken = defau } var backend = Backend ?? await ContainerBackends.ResolveAsync(cancellationToken).ConfigureAwait(false); - _container = backend.CreateContainer(WithResolvedName()); + _container = backend.CreateContainer(ConfigureForStart(WithResolvedName())); await _container.StartAsync(cancellationToken).ConfigureAwait(false); _configureConnectionStringProvider?.Invoke(); } @@ -166,6 +169,13 @@ public virtual async ValueTask DisposeAsync() GC.SuppressFinalize(this); } + /// + /// Adjusts the configuration the backend container is created from, immediately before it is created. Use + /// it for values only known at start time (for example the address of a dependency the module started + /// first). The default returns unchanged. + /// + protected virtual IContainerConfiguration ConfigureForStart(IContainerConfiguration configuration) => configuration; + /// /// Snapshots the configuration with the resolved container name so the backend creates the container /// under the name this instance reports. diff --git a/src/src/Core/IContainer.cs b/src/src/Core/IContainer.cs index a88dee5..98e98de 100644 --- a/src/src/Core/IContainer.cs +++ b/src/src/Core/IContainer.cs @@ -15,6 +15,13 @@ public interface IContainer : IConnectionStringProvider, IAsyncDisposable /// The image the container was created from. string Image { get; } + /// + /// The container's bridge-network IP address, available after start. Other containers on the same backend + /// reach this one at that address (there is no container-name DNS). null before start, or when the + /// container has no network (). + /// + string? NetworkIp { get; } + /// Starts the container: ensures the image, creates and starts the init process. Task StartAsync(CancellationToken cancellationToken = default); diff --git a/src/src/Docker/DockerContainer.cs b/src/src/Docker/DockerContainer.cs index 211822e..448c240 100644 --- a/src/src/Docker/DockerContainer.cs +++ b/src/src/Docker/DockerContainer.cs @@ -38,15 +38,21 @@ internal DockerContainer(IContainerConfiguration configuration, TcContainer cont /// public string Image => _configuration.Image; + /// + public string? NetworkIp { get; private set; } + /// public async Task StartAsync(CancellationToken cancellationToken = default) { ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) == 1, this); await Handle.StartAsync(cancellationToken).ConfigureAwait(false); + // Testcontainers reports an unspecified address (or none) when the container has no network. + NetworkIp = Handle.IpAddress is { Length: > 0 } ip && ip != "0.0.0.0" ? ip : null; + if (_configuration.WaitStrategies.Count > 0) { - WaitContext context = new(this, GetMappedPublicPorts(), networkIp: null); + WaitContext context = new(this, GetMappedPublicPorts(), NetworkIp); await WaitStrategyRunner .RunAsync(context, _configuration.WaitStrategies, _configuration.StartupTimeout, cancellationToken) .ConfigureAwait(false); diff --git a/src/src/MsSql/MsSqlBuilder.cs b/src/src/MsSql/MsSqlBuilder.cs index 91acfaf..4a9d0b3 100644 --- a/src/src/MsSql/MsSqlBuilder.cs +++ b/src/src/MsSql/MsSqlBuilder.cs @@ -12,7 +12,7 @@ public class MsSqlBuilder : ContainerBuilderDefault image. - public const string MsSqlImage = "mcr.microsoft.com/mssql/server:2022-latest"; + public const string MsSqlImage = "mcr.microsoft.com/mssql/server:2025-latest"; Secret _password = Secret.From("YourStrong!Passw0rd"); string _database = "master"; diff --git a/src/src/MsSql/Sdk/README.md b/src/src/MsSql/Sdk/README.md index 15a2083..7f16f11 100644 --- a/src/src/MsSql/Sdk/README.md +++ b/src/src/MsSql/Sdk/README.md @@ -45,7 +45,7 @@ await connection.OpenAsync(); | Member | Purpose | | -- | -- | -| `MsSqlBuilder()` / `MsSqlBuilder(string image)` | Default image `mcr.microsoft.com/mssql/server:2022-latest`, or a custom image. | +| `MsSqlBuilder()` / `MsSqlBuilder(string image)` | Default image `mcr.microsoft.com/mssql/server:2025-latest`, or a custom image. | | `MsSqlBuilder.MsSqlPort` (1433) | Container port, mapped to a random host port. | | `WithPassword(string)` | Sets `MSSQL_SA_PASSWORD` (default `YourStrong!Passw0rd`); stored as a redacted `Secret`. | | `WithDatabase(string)` | Sets the initial catalog the connection string points at (default `master`, matching Testcontainers). The database must already exist. | diff --git a/src/src/Wsl/WslContainer.cs b/src/src/Wsl/WslContainer.cs index 1236a3b..1eee2ac 100644 --- a/src/src/Wsl/WslContainer.cs +++ b/src/src/Wsl/WslContainer.cs @@ -55,6 +55,9 @@ public WslContainer(ContainerConfiguration configuration, IContainerRuntime runt /// public string Image => Configuration.Image; + /// + public string? NetworkIp => _networkIp; + /// public virtual async Task StartAsync(CancellationToken cancellationToken = default) { diff --git a/src/tests/AzureServiceBusEmulator.IntegrationTests/AzureServiceBusEmulator.IntegrationTests.csproj b/src/tests/AzureServiceBusEmulator.IntegrationTests/AzureServiceBusEmulator.IntegrationTests.csproj new file mode 100644 index 0000000..7267771 --- /dev/null +++ b/src/tests/AzureServiceBusEmulator.IntegrationTests/AzureServiceBusEmulator.IntegrationTests.csproj @@ -0,0 +1,5 @@ + + + + + diff --git a/src/tests/AzureServiceBusEmulator.IntegrationTests/AzureServiceBusEmulatorIntegrationTests.cs b/src/tests/AzureServiceBusEmulator.IntegrationTests/AzureServiceBusEmulatorIntegrationTests.cs new file mode 100644 index 0000000..0a10628 --- /dev/null +++ b/src/tests/AzureServiceBusEmulator.IntegrationTests/AzureServiceBusEmulatorIntegrationTests.cs @@ -0,0 +1,125 @@ +using System.Globalization; +using Azure.Messaging.ServiceBus; +using Azure.Messaging.ServiceBus.Administration; + +namespace Purview.Containers.AzureServiceBusEmulator; + +// Each test starts the emulator and its SQL Server sidecar, so the suite runs serially to keep the +// session VM (or Docker daemon) from starting several SQL Server instances at once. +[NotInParallel] +public class AzureServiceBusEmulatorIntegrationTests +{ + const string QueueName = "integration-queue"; + + const string Config = """ + { + "UserConfig": { + "Namespaces": [ + { + "Name": "sbemulatorns", + "Queues": [ + { + "Name": "integration-queue", + "Properties": { + "DeadLetteringOnMessageExpiration": false, + "DefaultMessageTimeToLive": "PT1H", + "DuplicateDetectionHistoryTimeWindow": "PT20S", + "ForwardDeadLetteredMessagesTo": "", + "ForwardTo": "", + "LockDuration": "PT1M", + "MaxDeliveryCount": 3, + "RequiresDuplicateDetection": false, + "RequiresSession": false + } + } + ], + "Topics": [] + } + ], + "Logging": { "Type": "File" } + } + } + """; + + [Test] + public async Task Emulator_SendsAndReceivesAMessage() + { + await WslcTest.SkipIfUnavailableAsync(); + + await using var emulator = new AzureServiceBusEmulatorBuilder().AcceptLicense().WithConfig(Config).Build(); + + await emulator.StartAsync(); + + await using ServiceBusClient client = new(emulator.GetConnectionString()); + await using var sender = client.CreateSender(QueueName); + await sender.SendMessageAsync(new ServiceBusMessage("hello")); + + await using var receiver = client.CreateReceiver(QueueName); + var message = await receiver.ReceiveMessageAsync(TimeSpan.FromSeconds(30)); + + await Assert.That(message).IsNotNull(); + await Assert.That(message!.Body.ToString()).IsEqualTo("hello"); + } + + [Test] + public async Task Emulator_ManagementConnectionString_ReachesTheAdministrationAPI() + { + await WslcTest.SkipIfUnavailableAsync(); + + await using var emulator = new AzureServiceBusEmulatorBuilder().AcceptLicense().WithConfig(Config).Build(); + + await emulator.StartAsync(); + + ServiceBusAdministrationClient administration = new(emulator.GetManagementConnectionString()); + var exists = await administration.QueueExistsAsync(QueueName); + + await Assert.That(exists.Value).IsTrue(); + } + + [Test] + public async Task Emulator_QueueCreatedAtRuntime_SendsAndReceives() + { + await WslcTest.SkipIfUnavailableAsync(); + + await using var emulator = new AzureServiceBusEmulatorBuilder().AcceptLicense().Build(); + + await emulator.StartAsync(); + + // A queue per test is how one shared emulator isolates concurrent tests. + var queueName = $"runtime-{Guid.NewGuid():N}"; + ServiceBusAdministrationClient administration = new(emulator.GetManagementConnectionString()); + await administration.CreateQueueAsync(queueName); + + await using ServiceBusClient client = new(emulator.GetConnectionString()); + await using var sender = client.CreateSender(queueName); + await sender.SendMessageAsync(new ServiceBusMessage("runtime")); + + await using var receiver = client.CreateReceiver(queueName); + var message = await receiver.ReceiveMessageAsync(TimeSpan.FromSeconds(30)); + + await Assert.That(message).IsNotNull(); + await Assert.That(message!.Body.ToString()).IsEqualTo("runtime"); + } + + [Test] + public async Task ConnectionStrings_UseTheMappedPorts() + { + await WslcTest.SkipIfUnavailableAsync(); + + await using var emulator = new AzureServiceBusEmulatorBuilder().AcceptLicense().Build(); + + await emulator.StartAsync(); + + var amqpPort = emulator.GetMappedPublicPort(AzureServiceBusEmulatorBuilder.AmqpPort); + var managementPort = emulator.GetMappedPublicPort(AzureServiceBusEmulatorBuilder.ManagementPort); + + await Assert + .That(emulator.GetConnectionString()) + .Contains($"Endpoint=sb://127.0.0.1:{amqpPort.ToString(CultureInfo.InvariantCulture)};"); + await Assert.That(emulator.GetConnectionString()).Contains("UseDevelopmentEmulator=true"); + await Assert + .That(emulator.GetManagementConnectionString()) + .Contains($"Endpoint=sb://127.0.0.1:{managementPort.ToString(CultureInfo.InvariantCulture)};"); + await Assert.That(emulator.SqlServer.NetworkIp).IsNotNull(); + } +} diff --git a/src/tests/AzureServiceBusEmulator.IntegrationTests/SharedServiceBusEmulator.cs b/src/tests/AzureServiceBusEmulator.IntegrationTests/SharedServiceBusEmulator.cs new file mode 100644 index 0000000..49047cb --- /dev/null +++ b/src/tests/AzureServiceBusEmulator.IntegrationTests/SharedServiceBusEmulator.cs @@ -0,0 +1,89 @@ +using Azure.Messaging.ServiceBus; +using Azure.Messaging.ServiceBus.Administration; +using Purview.Containers.Wsl; +using TUnit.Core.Interfaces; + +namespace Purview.Containers.AzureServiceBusEmulator; + +/// +/// One emulator for the whole test session, shared through +/// [ClassDataSource<SharedServiceBusEmulator>(Shared = SharedType.PerTestSession)]. The emulator +/// drops and recreates its fixed SQL Server databases on start, so instances cannot share a database +/// server; tests share one instance instead and isolate themselves with their own entities. +/// +public sealed class SharedServiceBusEmulator : IAsyncInitializer, IAsyncDisposable +{ + AzureServiceBusEmulatorContainer? _emulator; + + /// The started emulator. Tests call first. + public AzureServiceBusEmulatorContainer Emulator => + _emulator + ?? throw new InvalidOperationException("WSL Containers is unavailable; the emulator was not started."); + + /// Client for the AMQP endpoint, shared by every test. + public ServiceBusClient Client { get; private set; } = default!; + + /// Client for the management endpoint, used to create and delete per-test entities. + public ServiceBusAdministrationClient Administration { get; private set; } = default!; + + /// + public async Task InitializeAsync() + { + // Start only on a usable host, so the tests themselves skip (or fail under + // PURVIEW_CONTAINERS_REQUIRE_BACKEND) instead of the fixture failing every test. + WslcTest.EnsureBackendRegistered(); + var info = await WslContainerRuntime.Instance.GetInfoAsync(); + if (!info.IsAvailable || !info.IsCompatible) + { + return; + } + + _emulator = new AzureServiceBusEmulatorBuilder().AcceptLicense().Build(); + await _emulator.StartAsync(); + + Client = new ServiceBusClient(_emulator.GetConnectionString()); + Administration = new ServiceBusAdministrationClient(_emulator.GetManagementConnectionString()); + } + + /// Creates a uniquely named queue for one test; dispose the result to delete it. + public async Task CreateQueueAsync(string prefix = "q") + { + var name = UniqueName(prefix); + await Administration.CreateQueueAsync(name); + return new TestEntity(name, () => Administration.DeleteQueueAsync(name)); + } + + /// Creates a uniquely named topic with one subscription; dispose the result to delete both. + public async Task CreateTopicAsync(string subscription, string prefix = "t") + { + var name = UniqueName(prefix); + await Administration.CreateTopicAsync(name); + await Administration.CreateSubscriptionAsync(name, subscription); + return new TestEntity(name, () => Administration.DeleteTopicAsync(name)); + } + + /// + public async ValueTask DisposeAsync() + { + if (_emulator is null) + { + return; + } + + await Client.DisposeAsync(); + await _emulator.DisposeAsync(); + } + + // Short names: a GUID keeps them unique without approaching the entity name length limit. + static string UniqueName(string prefix) => $"{prefix}-{Guid.NewGuid():N}"; + + /// A per-test entity, deleted on dispose so the namespace stays under its entity quota. + public sealed class TestEntity(string name, Func delete) : IAsyncDisposable + { + /// The entity name. + public string Name { get; } = name; + + /// + public async ValueTask DisposeAsync() => await delete(); + } +} diff --git a/src/tests/AzureServiceBusEmulator.IntegrationTests/SharedServiceBusEmulatorTests.cs b/src/tests/AzureServiceBusEmulator.IntegrationTests/SharedServiceBusEmulatorTests.cs new file mode 100644 index 0000000..ed82503 --- /dev/null +++ b/src/tests/AzureServiceBusEmulator.IntegrationTests/SharedServiceBusEmulatorTests.cs @@ -0,0 +1,50 @@ +using Azure.Messaging.ServiceBus; + +namespace Purview.Containers.AzureServiceBusEmulator; + +// These tests deliberately run in parallel against one emulator: each owns its entities, so they never +// see each other's messages. This is the pattern the package README recommends. +[ClassDataSource(Shared = SharedType.PerTestSession)] +public class SharedServiceBusEmulatorTests(SharedServiceBusEmulator serviceBus) +{ + [Test] + [Arguments(1)] + [Arguments(2)] + [Arguments(3)] + [Arguments(4)] + public async Task SharedEmulator_QueuePerTest_ReceivesOnlyItsOwnMessages(int testNumber) + { + await WslcTest.SkipIfUnavailableAsync(); + + await using var queue = await serviceBus.CreateQueueAsync(); + await using var sender = serviceBus.Client.CreateSender(queue.Name); + await using var receiver = serviceBus.Client.CreateReceiver(queue.Name); + + var body = $"message-{testNumber}"; + await sender.SendMessageAsync(new ServiceBusMessage(body)); + + var message = await receiver.ReceiveMessageAsync(TimeSpan.FromSeconds(30)); + var unexpected = await receiver.ReceiveMessageAsync(TimeSpan.FromSeconds(2)); + + await Assert.That(message).IsNotNull(); + await Assert.That(message!.Body.ToString()).IsEqualTo(body); + await Assert.That(unexpected).IsNull(); + } + + [Test] + public async Task SharedEmulator_TopicPerTest_DeliversToItsSubscription() + { + await WslcTest.SkipIfUnavailableAsync(); + + await using var topic = await serviceBus.CreateTopicAsync("all"); + await using var sender = serviceBus.Client.CreateSender(topic.Name); + await using var receiver = serviceBus.Client.CreateReceiver(topic.Name, "all"); + + await sender.SendMessageAsync(new ServiceBusMessage("published")); + + var message = await receiver.ReceiveMessageAsync(TimeSpan.FromSeconds(30)); + + await Assert.That(message).IsNotNull(); + await Assert.That(message!.Body.ToString()).IsEqualTo("published"); + } +} diff --git a/src/tests/AzureServiceBusEmulator.UnitTests/AzureServiceBusEmulator.UnitTests.csproj b/src/tests/AzureServiceBusEmulator.UnitTests/AzureServiceBusEmulator.UnitTests.csproj new file mode 100644 index 0000000..e6c35ec --- /dev/null +++ b/src/tests/AzureServiceBusEmulator.UnitTests/AzureServiceBusEmulator.UnitTests.csproj @@ -0,0 +1 @@ + diff --git a/src/tests/AzureServiceBusEmulator.UnitTests/AzureServiceBusEmulatorBuilderTests.cs b/src/tests/AzureServiceBusEmulator.UnitTests/AzureServiceBusEmulatorBuilderTests.cs new file mode 100644 index 0000000..94ab4ab --- /dev/null +++ b/src/tests/AzureServiceBusEmulator.UnitTests/AzureServiceBusEmulatorBuilderTests.cs @@ -0,0 +1,105 @@ +using Purview.Containers.Runtime; +using Purview.Containers.Waiting; + +namespace Purview.Containers.AzureServiceBusEmulator; + +public class AzureServiceBusEmulatorBuilderTests +{ + [Test] + public async Task BuildConfig_AppliesDefaults() + { + var configuration = new AzureServiceBusEmulatorBuilder().AcceptLicense().BuildConfigurationForTesting(); + + await Assert.That(configuration.Image).IsEqualTo(AzureServiceBusEmulatorBuilder.EmulatorImage); + await Assert.That(configuration.PortBindings.Count).IsEqualTo(2); + await Assert.That(configuration.PortBindings).Contains(p => p.ContainerPort == 5672 && p.AssignRandomHostPort); + await Assert.That(configuration.PortBindings).Contains(p => p.ContainerPort == 5300 && p.AssignRandomHostPort); + await Assert.That(configuration.Environment["ACCEPT_EULA"]).IsEqualTo("Y"); + await Assert.That(configuration.Environment["SQL_WAIT_INTERVAL"]).IsEqualTo("0"); + await Assert + .That(configuration.Environment["MSSQL_SA_PASSWORD"]) + .IsEqualTo(AzureServiceBusEmulatorBuilder.DefaultSqlServerPassword); + await Assert.That(configuration.SqlServerImage).IsEqualTo(AzureServiceBusEmulatorBuilder.DefaultSqlServerImage); + await Assert.That(configuration.ConfigJson).IsNull(); + await Assert.That(configuration.WaitStrategies.Count).IsEqualTo(1); + await Assert.That(configuration.WaitStrategies[0]).IsTypeOf(); + } + + [Test] + public async Task Build_WithoutLicenseAcceptance_Throws() + { + AzureServiceBusEmulatorBuilder builder = new(); + + await Assert.That(() => builder.Build()).Throws(); + } + + [Test] + public async Task Build_WithAShortSqlServerPassword_Throws() + { + var builder = new AzureServiceBusEmulatorBuilder().AcceptLicense().WithSqlServerPassword("short"); + + await Assert.That(() => builder.Build()).Throws(); + } + + [Test] + public async Task BuildConfig_SqlServerSettings_AreHonoured() + { + var configuration = new AzureServiceBusEmulatorBuilder() + .AcceptLicense() + .WithSqlServerImage("mcr.microsoft.com/mssql/server:2025-CU9-ubuntu-24.04") + .WithSqlServerPassword("Another!Passw0rd") + .BuildConfigurationForTesting(); + + await Assert + .That(configuration.SqlServerImage) + .IsEqualTo("mcr.microsoft.com/mssql/server:2025-CU9-ubuntu-24.04"); + await Assert.That(configuration.SqlServerPassword.Value).IsEqualTo("Another!Passw0rd"); + await Assert.That(configuration.Environment["MSSQL_SA_PASSWORD"]).IsEqualTo("Another!Passw0rd"); + } + + [Test] + public async Task BuildConfig_Config_IsCarried() + { + const string json = """{ "UserConfig": { "Namespaces": [] } }"""; + + var configuration = new AzureServiceBusEmulatorBuilder() + .AcceptLicense() + .WithConfig(json) + .BuildConfigurationForTesting(); + + await Assert.That(configuration.ConfigJson).IsEqualTo(json); + } + + [Test] + public async Task WithConfigFile_MissingFile_Throws() + { + AzureServiceBusEmulatorBuilder builder = new(); + + await Assert + .That(() => builder.WithConfigFile(Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.json"))) + .Throws(); + } + + [Test] + public async Task ToString_RedactsTheSqlServerPassword() + { + var configuration = new AzureServiceBusEmulatorBuilder() + .AcceptLicense() + .WithSqlServerPassword("Another!Passw0rd") + .BuildConfigurationForTesting(); + + await Assert.That(configuration.ToString()).DoesNotContain("Another!Passw0rd"); + } + + [Test] + public async Task BuildConfig_PreservesCustomWaitStrategy() + { + var configuration = new AzureServiceBusEmulatorBuilder() + .AcceptLicense() + .WithWaitStrategy(Wait.ForTcpPort(5672)) + .BuildConfigurationForTesting(); + + await Assert.That(configuration.WaitStrategies.Count).IsEqualTo(1); + await Assert.That(configuration.WaitStrategies[0]).IsTypeOf(); + } +} diff --git a/src/tests/Azurite.IntegrationTests/Azurite.IntegrationTests.csproj b/src/tests/Azurite.IntegrationTests/Azurite.IntegrationTests.csproj index e6c35ec..5500ad3 100644 --- a/src/tests/Azurite.IntegrationTests/Azurite.IntegrationTests.csproj +++ b/src/tests/Azurite.IntegrationTests/Azurite.IntegrationTests.csproj @@ -1 +1,5 @@ - + + + + + diff --git a/src/tests/Azurite.IntegrationTests/AzuriteIntegrationTests.cs b/src/tests/Azurite.IntegrationTests/AzuriteIntegrationTests.cs index 4c07c9c..4ae9ea7 100644 --- a/src/tests/Azurite.IntegrationTests/AzuriteIntegrationTests.cs +++ b/src/tests/Azurite.IntegrationTests/AzuriteIntegrationTests.cs @@ -1,8 +1,32 @@ +using Azure.Storage.Blobs; + namespace Purview.Containers.Azurite; [NotInParallel] public class AzuriteIntegrationTests { + [Test] + public async Task ConnectionString_AuthenticatesWithTheWellKnownAccountKey() + { + await WslcTest.SkipIfUnavailableAsync(); + + await using var azurite = new AzuriteBuilder().Build(); + + await azurite.StartAsync(); + + // Creating a container is a shared-key-signed request: Azurite rejects it unless the key is right. + // The service version is pinned because the SDK can be ahead of what the Azurite image supports. + BlobServiceClient client = new( + azurite.GetConnectionString(), + new BlobClientOptions(BlobClientOptions.ServiceVersion.V2024_11_04) + ); + var container = await client.CreateBlobContainerAsync("authenticated"); + + var exists = await container.Value.ExistsAsync(); + + await Assert.That(exists.Value).IsTrue(); + } + [Test] public async Task Azurite_StartsAndExposesEndpoints() { diff --git a/src/tests/Modules.Docker.IntegrationTests/ModuleDockerTests.cs b/src/tests/Modules.Docker.IntegrationTests/ModuleDockerTests.cs index 62977c8..d7cb7a9 100644 --- a/src/tests/Modules.Docker.IntegrationTests/ModuleDockerTests.cs +++ b/src/tests/Modules.Docker.IntegrationTests/ModuleDockerTests.cs @@ -1,4 +1,6 @@ using System.Globalization; +using Azure.Messaging.ServiceBus; +using Azure.Storage.Blobs; using Purview.Containers.Docker; using TUnit.Core.Exceptions; @@ -134,6 +136,48 @@ public async Task Azurite_StartsAndMapsItsPort() await Assert.That(azurite.GetBlobEndpoint().Port).IsGreaterThan(0); } + [Test] + public async Task Azurite_AuthenticatesWithTheWellKnownAccountKey() + { + await SkipIfUnavailableAsync(); + + await using var azurite = new Azurite.AzuriteBuilder().Build(); + + await AssertStartedAsync(azurite, Azurite.AzuriteBuilder.BlobPort); + + // Creating a container is a shared-key-signed request: Azurite rejects it unless the key is right. + // The service version is pinned because the SDK can be ahead of what the Azurite image supports. + BlobServiceClient client = new( + azurite.GetConnectionString(), + new BlobClientOptions(BlobClientOptions.ServiceVersion.V2024_11_04) + ); + var container = await client.CreateBlobContainerAsync("authenticated"); + var exists = await container.Value.ExistsAsync(); + + await Assert.That(exists.Value).IsTrue(); + } + + [Test] + public async Task AzureServiceBusEmulator_SendsAndReceivesAMessage() + { + await SkipIfUnavailableAsync(); + + // No custom Config.json: the image's default configuration declares the queue 'queue.1'. + await using var emulator = new AzureServiceBusEmulator.AzureServiceBusEmulatorBuilder().AcceptLicense().Build(); + + await AssertStartedAsync(emulator, AzureServiceBusEmulator.AzureServiceBusEmulatorBuilder.AmqpPort); + + await using ServiceBusClient client = new(emulator.GetConnectionString()); + await using var sender = client.CreateSender("queue.1"); + await sender.SendMessageAsync(new ServiceBusMessage("hello")); + + await using var receiver = client.CreateReceiver("queue.1"); + var message = await receiver.ReceiveMessageAsync(TimeSpan.FromSeconds(30)); + + await Assert.That(message).IsNotNull(); + await Assert.That(message!.Body.ToString()).IsEqualTo("hello"); + } + [Test] public async Task Nats_StartsAndMapsItsPort() { diff --git a/src/tests/Modules.Docker.IntegrationTests/Modules.Docker.IntegrationTests.csproj b/src/tests/Modules.Docker.IntegrationTests/Modules.Docker.IntegrationTests.csproj index 55b1138..4548b03 100644 --- a/src/tests/Modules.Docker.IntegrationTests/Modules.Docker.IntegrationTests.csproj +++ b/src/tests/Modules.Docker.IntegrationTests/Modules.Docker.IntegrationTests.csproj @@ -11,6 +11,7 @@ + @@ -21,6 +22,11 @@ + + + + +