From e9a873c152486d90b2805326c429712f5b7d2d0c Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Tue, 30 Jun 2026 17:24:56 +0200 Subject: [PATCH] test(domain): mutation baseline 90 (achieved 97.7%) + CI/Makefile wiring (refs #6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stryker.NET config for the domain service (break 90, the repo's ratchet floor), excluding the OpenZaakJobPump hosted-shell from mutation. Hardened the unit tests to kill survivors — Basic-credential value, variable types, null/failure response paths, option defaults, guard clauses, save counts and log output — leaving only two documented equivalent mutants (Stryker-disabled). make mutation runs the domain ratchet and CI uploads its report alongside the others. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitea/workflows/ci.yaml | 6 +++ Makefile | 6 ++- services/domain/Big.Domain/Registration.cs | 2 + .../FlowableWorkflowClient.cs | 2 + .../domain/Big.Tests/AclHttpClientTests.cs | 3 +- services/domain/Big.Tests/CapturingLogger.cs | 24 +++++++++ .../Big.Tests/FlowableWorkflowClientTests.cs | 52 +++++++++++++++++-- .../InMemoryRegistrationStoreTests.cs | 8 +++ .../Big.Tests/OpenZaakJobProcessorTests.cs | 8 ++- .../domain/Big.Tests/OpenZaakWorkerTests.cs | 14 ++++- .../domain/Big.Tests/RegistrationTests.cs | 15 +++++- .../Big.Tests/SubmitRegistrationTests.cs | 14 +++++ services/domain/stryker-config.json | 15 ++++++ 13 files changed, 159 insertions(+), 10 deletions(-) create mode 100644 services/domain/Big.Tests/CapturingLogger.cs create mode 100644 services/domain/stryker-config.json diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 8b90f88..9e3c95b 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -68,6 +68,12 @@ jobs: name: event-subscriber-mutation-report path: services/event-subscriber/StrykerOutput/**/reports/mutation-report.html if-no-files-found: warn + - uses: https://github.com/actions/upload-artifact@v3 + if: always() + with: + name: domain-mutation-report + path: services/domain/StrykerOutput/**/reports/mutation-report.html + if-no-files-found: warn # One stage for every check that needs the live stack. On the single self-hosted # runner jobs run sequentially, so booting OpenZaak once (instead of once per job) diff --git a/Makefile b/Makefile index 0f8d469..0f65935 100644 --- a/Makefile +++ b/Makefile @@ -64,12 +64,14 @@ unit: ## mutation: run the Stryker.NET ratchet on each service with branching logic (fails below baseline) # Stryker is pinned as a local dotnet tool (.config/dotnet-tools.json); `tool restore` # makes `make mutation` work from a fresh clone. Each service owns its config + break -# threshold (the ratchet, CLAUDE.md §5): services/acl/stryker-config.json and -# services/event-subscriber/stryker-config.json. Scores never regress below baseline. +# threshold (the ratchet, CLAUDE.md §5): services/acl/stryker-config.json, +# services/event-subscriber/stryker-config.json and services/domain/stryker-config.json. +# Scores never regress below baseline. mutation: dotnet tool restore cd services/acl && dotnet stryker cd services/event-subscriber && dotnet stryker + cd services/domain && dotnet stryker ## smoke: seed config, bring the whole stack up, wait for health-checked services, tear down # SEED populates the external config volumes first (upstream images used verbatim; diff --git a/services/domain/Big.Domain/Registration.cs b/services/domain/Big.Domain/Registration.cs index c5d6f8c..d6f2d85 100644 --- a/services/domain/Big.Domain/Registration.cs +++ b/services/domain/Big.Domain/Registration.cs @@ -57,6 +57,8 @@ public sealed class Registration if (ZaakUrl != zaakUrl) throw new InvalidOperationException( $"Registration {Id} already has zaak {ZaakUrl}; cannot attach a different zaak {zaakUrl}."); + // Stryker disable once Statement : equivalent — re-assigning the identical URL below is a + // no-op, so removing this early return is behaviourally indistinguishable. return; } diff --git a/services/domain/Big.Infrastructure/FlowableWorkflowClient.cs b/services/domain/Big.Infrastructure/FlowableWorkflowClient.cs index ee153ac..0b6ba23 100644 --- a/services/domain/Big.Infrastructure/FlowableWorkflowClient.cs +++ b/services/domain/Big.Infrastructure/FlowableWorkflowClient.cs @@ -102,6 +102,8 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti { /// The registration id this job carries as a process variable. public string RegistrationId() => + // Stryker disable once Linq : equivalent — Single and SingleOrDefault both throw on a job + // with no registrationId variable (the only untested branch), so the mutant is indistinguishable. Variables.SingleOrDefault(v => v.Name == "registrationId")?.Value ?? throw new InvalidOperationException($"OpenZaakAanmaken job {Id} carries no registrationId variable."); } diff --git a/services/domain/Big.Tests/AclHttpClientTests.cs b/services/domain/Big.Tests/AclHttpClientTests.cs index a08f174..2818b7d 100644 --- a/services/domain/Big.Tests/AclHttpClientTests.cs +++ b/services/domain/Big.Tests/AclHttpClientTests.cs @@ -38,6 +38,7 @@ public class AclHttpClientTests var capture = new RequestCapture(); var client = Client(capture.Responds(HttpStatusCode.OK, "null")); - await Assert.ThrowsAsync(() => client.OpenZaakAsync("123456782")); + var ex = await Assert.ThrowsAsync(() => client.OpenZaakAsync("123456782")); + Assert.Contains("empty", ex.Message, StringComparison.OrdinalIgnoreCase); } } diff --git a/services/domain/Big.Tests/CapturingLogger.cs b/services/domain/Big.Tests/CapturingLogger.cs new file mode 100644 index 0000000..d8231e0 --- /dev/null +++ b/services/domain/Big.Tests/CapturingLogger.cs @@ -0,0 +1,24 @@ +using Microsoft.Extensions.Logging; + +namespace Big.Tests; + +/// A minimal that records the level and rendered message of each +/// entry, so tests can assert that (for example) a failing job was logged. +internal sealed class CapturingLogger : ILogger +{ + public List<(LogLevel Level, string Message)> Entries { get; } = []; + + public IDisposable BeginScope(TState state) where TState : notnull => NullScope.Instance; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, + Func formatter) + => Entries.Add((logLevel, formatter(state, exception))); + + private sealed class NullScope : IDisposable + { + public static readonly NullScope Instance = new(); + public void Dispose() { } + } +} diff --git a/services/domain/Big.Tests/FlowableWorkflowClientTests.cs b/services/domain/Big.Tests/FlowableWorkflowClientTests.cs index e1b3b23..625d69f 100644 --- a/services/domain/Big.Tests/FlowableWorkflowClientTests.cs +++ b/services/domain/Big.Tests/FlowableWorkflowClientTests.cs @@ -1,4 +1,5 @@ using System.Net; +using System.Text; using Big.Domain; using Big.Infrastructure; @@ -12,6 +13,9 @@ public class FlowableWorkflowClientTests new HttpClient(handler), new FlowableOptions { BaseUrl = Base, Username = "rest-admin", Password = "test", WorkerId = "worker-x" }); + private static string DecodeBasic(HttpRequestMessage request) + => Encoding.ASCII.GetString(Convert.FromBase64String(request.Headers.Authorization!.Parameter!)); + [Fact] public async Task Start_posts_the_registration_id_variable_and_returns_the_instance_id() { @@ -26,11 +30,29 @@ public class FlowableWorkflowClientTests Assert.Equal("http://flowable/flowable-rest/service/runtime/process-instances", capture.Seen.RequestUri!.ToString()); Assert.Equal("Basic", capture.Seen.Headers.Authorization!.Scheme); + Assert.Equal("rest-admin:test", DecodeBasic(capture.Seen)); Assert.Contains("\"processDefinitionKey\":\"registratie\"", capture.Body); Assert.Contains("\"name\":\"registrationId\"", capture.Body); + Assert.Contains("\"type\":\"string\"", capture.Body); Assert.Contains($"\"value\":\"{rid}\"", capture.Body); } + [Fact] + public async Task Start_uses_the_configured_worker_credentials_and_defaults() + { + var capture = new RequestCapture(); + // Only BaseUrl set — the worker id and (empty) credentials must come from the defaults. + var client = new FlowableWorkflowClient( + new HttpClient(capture.Responds(HttpStatusCode.OK, "[]")), + new FlowableOptions { BaseUrl = Base }); + + await client.AcquireOpenZaakJobsAsync(1); + + Assert.Equal(":", DecodeBasic(capture.Seen!)); + Assert.Contains("\"workerId\":\"big-domain-worker\"", capture.Body); + Assert.Contains("\"lockDuration\":\"PT5M\"", capture.Body); + } + [Fact] public async Task Acquire_posts_the_topic_and_parses_jobs_with_their_registration_id() { @@ -52,11 +74,13 @@ public class FlowableWorkflowClientTests Assert.Contains("\"lockDuration\":\"PT5M\"", capture.Body); } - [Fact] - public async Task Acquire_returns_empty_when_flowable_has_no_parked_jobs() + [Theory] + [InlineData("[]")] + [InlineData("null")] + public async Task Acquire_returns_empty_when_flowable_has_no_parked_jobs(string body) { var capture = new RequestCapture(); - var client = Client(capture.Responds(HttpStatusCode.OK, "[]")); + var client = Client(capture.Responds(HttpStatusCode.OK, body)); var jobs = await client.AcquireOpenZaakJobsAsync(1); @@ -64,6 +88,17 @@ public class FlowableWorkflowClientTests Assert.Empty(jobs); } + [Fact] + public async Task Acquire_throws_when_a_job_carries_no_registration_id() + { + var capture = new RequestCapture(); + var client = Client(capture.Responds(HttpStatusCode.OK, """[{"id":"job-1","variables":[]}]""")); + + var ex = await Assert.ThrowsAsync(() => client.AcquireOpenZaakJobsAsync(1)); + Assert.Contains("job-1", ex.Message); + Assert.Contains("registrationId", ex.Message); + } + [Fact] public async Task Complete_posts_the_zaak_url_variable_to_the_job_complete_endpoint() { @@ -78,6 +113,7 @@ public class FlowableWorkflowClientTests capture.Seen.RequestUri!.ToString()); Assert.Contains("\"workerId\":\"worker-x\"", capture.Body); Assert.Contains("\"name\":\"zaakUrl\"", capture.Body); + Assert.Contains("\"type\":\"string\"", capture.Body); Assert.Contains("\"value\":\"http://openzaak/zaken/api/v1/zaken/abc\"", capture.Body); } @@ -90,4 +126,14 @@ public class FlowableWorkflowClientTests await Assert.ThrowsAsync( () => client.StartRegistrationProcessAsync(RegistrationId.New())); } + + [Fact] + public async Task Complete_throws_when_flowable_rejects_the_request() + { + var capture = new RequestCapture(); + var client = Client(capture.Responds(HttpStatusCode.InternalServerError)); + + await Assert.ThrowsAsync( + () => client.CompleteOpenZaakJobAsync("job-1", new Uri("http://openzaak/zaken/api/v1/zaken/abc"))); + } } diff --git a/services/domain/Big.Tests/InMemoryRegistrationStoreTests.cs b/services/domain/Big.Tests/InMemoryRegistrationStoreTests.cs index 8faaae6..1a63c57 100644 --- a/services/domain/Big.Tests/InMemoryRegistrationStoreTests.cs +++ b/services/domain/Big.Tests/InMemoryRegistrationStoreTests.cs @@ -33,4 +33,12 @@ public class InMemoryRegistrationStoreTests Assert.NotNull(loaded); Assert.Equal("http://openzaak/zaken/api/v1/zaken/abc", loaded.ZaakUrl!.ToString()); } + + [Fact] + public async Task Saving_a_null_registration_is_rejected() + { + var store = new InMemoryRegistrationStore(); + + await Assert.ThrowsAsync(() => store.SaveAsync(null!)); + } } diff --git a/services/domain/Big.Tests/OpenZaakJobProcessorTests.cs b/services/domain/Big.Tests/OpenZaakJobProcessorTests.cs index 35a0c22..487e636 100644 --- a/services/domain/Big.Tests/OpenZaakJobProcessorTests.cs +++ b/services/domain/Big.Tests/OpenZaakJobProcessorTests.cs @@ -1,6 +1,7 @@ using Big.Application; using Big.Domain; using Big.Infrastructure; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; namespace Big.Tests; @@ -54,11 +55,16 @@ public class OpenZaakJobProcessorTests var acl = new FakeAclClient(); // The job correlates to a registration that is not in the store, so the worker throws. var client = new FakeExternalWorkerClient(new OpenZaakJob("job-1", RegistrationId.New())); + var logger = new CapturingLogger(); + var processor = new OpenZaakJobProcessor(client, new OpenZaakWorker(store, acl), logger); - var acquired = await Processor(client, store, acl).PumpOnceAsync(5); + var acquired = await processor.PumpOnceAsync(5); Assert.Equal(1, acquired); Assert.Empty(client.Completed); + // The failure is logged (and the job left for redelivery), not swallowed silently. + var error = Assert.Single(logger.Entries, e => e.Level == LogLevel.Error); + Assert.Contains("job-1", error.Message); } [Fact] diff --git a/services/domain/Big.Tests/OpenZaakWorkerTests.cs b/services/domain/Big.Tests/OpenZaakWorkerTests.cs index 2213d59..a57af38 100644 --- a/services/domain/Big.Tests/OpenZaakWorkerTests.cs +++ b/services/domain/Big.Tests/OpenZaakWorkerTests.cs @@ -27,6 +27,15 @@ public class OpenZaakWorkerTests Assert.Equal("123456782", acl.OpenedForBsn); var saved = await store.GetAsync(registration.Id); Assert.Equal(FakeAclClient.DefaultZaakUrl, saved!.ZaakUrl); + Assert.Equal(1, store.SaveCount); + } + + [Fact] + public async Task Handling_a_null_job_is_rejected() + { + var worker = new OpenZaakWorker(new FakeRegistrationStore(), new FakeAclClient()); + + await Assert.ThrowsAsync(() => worker.HandleAsync(null!)); } [Fact] @@ -36,8 +45,9 @@ public class OpenZaakWorkerTests var acl = new FakeAclClient(); var worker = new OpenZaakWorker(store, acl); - await Assert.ThrowsAsync( - () => worker.HandleAsync(new OpenZaakJob("job-1", RegistrationId.New()))); + var job = new OpenZaakJob("job-1", RegistrationId.New()); + var ex = await Assert.ThrowsAsync(() => worker.HandleAsync(job)); + Assert.Contains(job.RegistrationId.ToString(), ex.Message); Assert.Equal(0, acl.CallCount); } diff --git a/services/domain/Big.Tests/RegistrationTests.cs b/services/domain/Big.Tests/RegistrationTests.cs index 85b2e8a..a8463a0 100644 --- a/services/domain/Big.Tests/RegistrationTests.cs +++ b/services/domain/Big.Tests/RegistrationTests.cs @@ -33,6 +33,17 @@ public class RegistrationTests Assert.Equal("proc-42", registration.ProcessInstanceId); } + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + public void Recording_an_empty_process_instance_id_is_rejected(string? processInstanceId) + { + var registration = Registration.Submit("123456782"); + + Assert.ThrowsAny(() => registration.RecordProcessStarted(processInstanceId!)); + } + [Fact] public void Attaching_a_zaak_records_its_url_and_keeps_the_status_ingediend() { @@ -71,7 +82,9 @@ public class RegistrationTests var registration = Registration.Submit("123456782"); registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc")); - Assert.Throws( + var ex = Assert.Throws( () => registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/other"))); + Assert.Contains("/zaken/abc", ex.Message); + Assert.Contains("/zaken/other", ex.Message); } } diff --git a/services/domain/Big.Tests/SubmitRegistrationTests.cs b/services/domain/Big.Tests/SubmitRegistrationTests.cs index fd3c097..29974bf 100644 --- a/services/domain/Big.Tests/SubmitRegistrationTests.cs +++ b/services/domain/Big.Tests/SubmitRegistrationTests.cs @@ -21,6 +21,20 @@ public class SubmitRegistrationTests Assert.Equal("proc-42", saved.ProcessInstanceId); Assert.Equal(id, workflow.StartedFor); Assert.Null(saved.ZaakUrl); + // Persisted twice: once before starting the process, once after recording the instance id. + Assert.Equal(2, store.SaveCount); + } + + [Fact] + public async Task Rejects_a_null_command_without_touching_the_store_or_workflow() + { + var store = new FakeRegistrationStore(); + var workflow = new FakeWorkflowClient(); + var handler = new SubmitRegistration(store, workflow); + + await Assert.ThrowsAsync(() => handler.HandleAsync(null!)); + Assert.Equal(0, store.SaveCount); + Assert.Null(workflow.StartedFor); } [Fact] diff --git a/services/domain/stryker-config.json b/services/domain/stryker-config.json new file mode 100644 index 0000000..935dfd7 --- /dev/null +++ b/services/domain/stryker-config.json @@ -0,0 +1,15 @@ +{ + "stryker-config": { + "solution": "Big.slnx", + "test-projects": ["Big.Tests/Big.Tests.csproj"], + "reporters": ["progress", "html"], + "mutate": [ + "!**/OpenZaakJobPump.cs" + ], + "thresholds": { + "high": 95, + "low": 90, + "break": 90 + } + } +}