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
+ }
+ }
+}