diff --git a/.gitignore b/.gitignore index 21f4674..91aea34 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,9 @@ obj/ [Rr]elease/ *.user +# Reqnroll-generated test code (regenerated from *.feature on build) +*.feature.cs + # Test results / coverage [Tt]est[Rr]esults/ *.trx diff --git a/docs/architecture/adr-0004-bdd-framework.md b/docs/architecture/adr-0004-bdd-framework.md new file mode 100644 index 0000000..546b72e --- /dev/null +++ b/docs/architecture/adr-0004-bdd-framework.md @@ -0,0 +1,52 @@ +# ADR-0004: Reqnroll as the BDD acceptance framework + +- **Status:** Accepted +- **Date:** 2026-06-04 +- **Deciders:** Respellion engineering +- **Relates to:** S-04 (#5); supports CLAUDE.md §3 (BDD at the use-case level) and §11 (tests pyramid) + +## Context + +CLAUDE.md §11 mandates that each user-visible flow is driven by a Gherkin acceptance +scenario living in `tests/acceptance/`, and §3 names "BDD at the use-case level" as a core +engineering principle. The foundational slices (S-00…S-03) added no acceptance layer; S-04 +is the first slice with real domain behaviour to drive, so it is where the BDD framework is +introduced. We need a .NET tool that: + +- parses Gherkin `.feature` files and binds steps to C#, +- integrates with the existing xUnit test runner (the repo standardises on xUnit), so + acceptance tests run under the same `dotnet test` / `make ci` gate as everything else, +- is actively maintained on modern .NET (we target net10.0). + +## Decision + +**Use [Reqnroll](https://reqnroll.net/) (`Reqnroll.xUnit`) for acceptance tests.** + +- Reqnroll is the actively-maintained, open-source successor to SpecFlow (which is no longer + maintained). It keeps the same Gherkin + `[Binding]` model, so the knowledge transfers. +- `Reqnroll.xUnit` generates one xUnit test per scenario, so acceptance tests are discovered + and run by the same runner as the unit tests — no second test framework, no extra CI step. +- Acceptance projects live under `tests/acceptance/` per the PRD §9 layout. Generated + `*.feature.cs` files are build artefacts and are git-ignored. + +## Consequences + +- **Positive:** one assertion/runner stack (xUnit) across unit and acceptance tests; scenarios + are written in business language (Dutch domain terms inline) and reviewed as the slice's + contract; maintained tooling on net10.0. +- **Cost:** a new dependency (`Reqnroll.xUnit`) and its xUnit v2 transitive graph. Reqnroll + pulls `xunit.core` but not the assertion library, so the `xunit` metapackage is referenced + explicitly to get `Assert`. +- **Replaceable by:** hand-written xUnit "scenario" tests with a Given/When/Then helper, at + the cost of losing Gherkin as the shared, readable contract — which is the whole point of §3. +- **Follow-ups:** the real-OpenZaak integration test (Testcontainers) and the Stryker mutation + baseline for S-04 are tracked as their own issues split off #5. + +## Alternatives considered + +- **SpecFlow** — rejected: unmaintained and without an official net10.0 story; Reqnroll is its + drop-in successor. +- **Plain xUnit Given/When/Then helpers** — rejected for user-visible flows: loses the + business-readable Gherkin contract that §3/§11 require. Still fine for unit-level tests. +- **Xunit.Gherkin.Quick** — rejected: lighter but less featureful (no hooks/scoped contexts, + smaller community) than Reqnroll. diff --git a/mkdocs.yml b/mkdocs.yml index 6f4e752..92e4c5b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -23,6 +23,9 @@ nav: - Product Requirements: PRD.md - Architecture: - "ADR-0001: Loose coupling": architecture/adr-0001-loose-coupling.md + - "ADR-0002: Catalogus design": architecture/adr-0002-catalogus-design.md + - "ADR-0003: ACL default-fill": architecture/adr-0003-default-fill.md + - "ADR-0004: BDD framework": architecture/adr-0004-bdd-framework.md - Working in Gitea: gitea-workflow.md - Runbooks: - CI: runbooks/ci.md diff --git a/register-referentie.slnx b/register-referentie.slnx index bf80fac..fe5cf56 100644 --- a/register-referentie.slnx +++ b/register-referentie.slnx @@ -10,4 +10,7 @@ + + + diff --git a/tests/acceptance/Acceptance.csproj b/tests/acceptance/Acceptance.csproj new file mode 100644 index 0000000..c182441 --- /dev/null +++ b/tests/acceptance/Acceptance.csproj @@ -0,0 +1,23 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + diff --git a/tests/acceptance/Features/EenZaakOpenen.feature b/tests/acceptance/Features/EenZaakOpenen.feature new file mode 100644 index 0000000..8b7e41c --- /dev/null +++ b/tests/acceptance/Features/EenZaakOpenen.feature @@ -0,0 +1,27 @@ +# language: en +# Drives S-04 (#5). The ACL is the only code that talks to ZGW (ADR-0001); it +# default-fills the ZGW-mandatory zaak fields the domain never sees (ADR-0003). +# Real-OpenZaak verification is a separate slice — this scenario exercises the +# use case against an in-memory stand-in for the Zaken API. +Feature: Een zaak openen vanuit een registratie + Als domein wil ik de ACL vragen een zaak te openen + zodat de ZGW-verplichte velden worden ingevuld zonder dat het domein ze kent. + + Scenario: De ACL vult de ZGW-verplichte velden default in + Given a domain registration for BSN "123456782" + And the ACL is configured with these defaults: + | field | value | + | bronorganisatie | 517439943 | + | verantwoordelijkeOrganisatie | 517439943 | + | vertrouwelijkheidaanduiding | openbaar | + | zaaktype | http://openzaak/catalogi/api/v1/zaaktypen/big | + And today is "2026-06-04" + When the domain asks the ACL to open a zaak + Then a zaak is created with these default-filled fields + | field | value | + | bronorganisatie | 517439943 | + | verantwoordelijkeOrganisatie | 517439943 | + | vertrouwelijkheidaanduiding | openbaar | + | zaaktype | http://openzaak/catalogi/api/v1/zaaktypen/big | + | startdatum | 2026-06-04 | + And the ACL returns the URL of the created zaak diff --git a/tests/acceptance/Steps/EenZaakOpenenSteps.cs b/tests/acceptance/Steps/EenZaakOpenenSteps.cs new file mode 100644 index 0000000..8c191bc --- /dev/null +++ b/tests/acceptance/Steps/EenZaakOpenenSteps.cs @@ -0,0 +1,65 @@ +using Acceptance.Support; +using Acl.Application; +using Reqnroll; +using Xunit; + +namespace Acceptance.Steps; + +/// Bindings for EenZaakOpenen.feature. Reqnroll creates one +/// instance per scenario, so instance fields hold scenario-scoped state. +[Binding] +public sealed class EenZaakOpenenSteps +{ + private readonly InMemoryZaakGateway _gateway = new(); + private DomainRegistration? _registration; + private AclDefaults? _defaults; + private DateOnly _today; + private Uri? _returnedUrl; + + [Given("a domain registration for BSN \"(.*)\"")] + public void GivenADomainRegistrationForBsn(string bsn) + => _registration = new DomainRegistration(bsn); + + [Given("the ACL is configured with these defaults:")] + public void GivenTheAclIsConfiguredWithTheseDefaults(DataTable defaults) + { + var values = ToFieldMap(defaults); + _defaults = new AclDefaults + { + Bronorganisatie = values["bronorganisatie"], + VerantwoordelijkeOrganisatie = values["verantwoordelijkeOrganisatie"], + Vertrouwelijkheidaanduiding = values["vertrouwelijkheidaanduiding"], + ZaaktypeUrl = new Uri(values["zaaktype"]), + }; + } + + [Given("today is \"(.*)\"")] + public void GivenTodayIs(string date) + => _today = DateOnly.Parse(date); + + [When("the domain asks the ACL to open a zaak")] + public async Task WhenTheDomainAsksTheAclToOpenAZaak() + { + var service = new AclService(_gateway, _defaults!, new FixedClock(_today)); + _returnedUrl = await service.OpenZaakAsync(_registration!); + } + + [Then("a zaak is created with these default-filled fields")] + public void ThenAZaakIsCreatedWithTheseDefaultFilledFields(DataTable expected) + { + var request = Assert.IsType(_gateway.Captured); + var fields = ToFieldMap(expected); + Assert.Equal(fields["bronorganisatie"], request.Bronorganisatie); + Assert.Equal(fields["verantwoordelijkeOrganisatie"], request.VerantwoordelijkeOrganisatie); + Assert.Equal(fields["vertrouwelijkheidaanduiding"], request.Vertrouwelijkheidaanduiding); + Assert.Equal(fields["zaaktype"], request.Zaaktype.ToString()); + Assert.Equal(fields["startdatum"], request.Startdatum.ToString("yyyy-MM-dd")); + } + + [Then("the ACL returns the URL of the created zaak")] + public void ThenTheAclReturnsTheUrlOfTheCreatedZaak() + => Assert.Equal(InMemoryZaakGateway.CreatedZaakUrl, _returnedUrl); + + private static Dictionary ToFieldMap(DataTable table) + => table.Rows.ToDictionary(r => r["field"], r => r["value"]); +} diff --git a/tests/acceptance/Support/FixedClock.cs b/tests/acceptance/Support/FixedClock.cs new file mode 100644 index 0000000..b6d8dce --- /dev/null +++ b/tests/acceptance/Support/FixedClock.cs @@ -0,0 +1,10 @@ +using Acl.Application; + +namespace Acceptance.Support; + +/// A clock pinned to a known date so startdatum is deterministic +/// in scenarios (ADR-0003). +public sealed class FixedClock(DateOnly today) : IClock +{ + public DateOnly Today { get; } = today; +} diff --git a/tests/acceptance/Support/InMemoryZaakGateway.cs b/tests/acceptance/Support/InMemoryZaakGateway.cs new file mode 100644 index 0000000..a1dde7b --- /dev/null +++ b/tests/acceptance/Support/InMemoryZaakGateway.cs @@ -0,0 +1,20 @@ +using Acl.Application; + +namespace Acceptance.Support; + +/// An in-memory stand-in for the OpenZaak Zaken API. It captures the +/// fully default-filled the ACL builds and returns a +/// fixed zaak URL, so the acceptance scenario can verify the use case without a +/// running OpenZaak (real-OpenZaak verification is a separate slice). +public sealed class InMemoryZaakGateway : IZaakGateway +{ + public static readonly Uri CreatedZaakUrl = new("http://openzaak/zaken/api/v1/zaken/created-123"); + + public ZaakRequest? Captured { get; private set; } + + public Task OpenZaakAsync(ZaakRequest request, CancellationToken ct = default) + { + Captured = request; + return Task.FromResult(CreatedZaakUrl); + } +}