test(domain): mutation baseline 90 (achieved 97.7%) + CI/Makefile wiring (refs #6)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -102,6 +102,8 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
|
||||
{
|
||||
/// <summary>The registration id this job carries as a process variable.</summary>
|
||||
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.");
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ public class AclHttpClientTests
|
||||
var capture = new RequestCapture();
|
||||
var client = Client(capture.Responds(HttpStatusCode.OK, "null"));
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => client.OpenZaakAsync("123456782"));
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => client.OpenZaakAsync("123456782"));
|
||||
Assert.Contains("empty", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
24
services/domain/Big.Tests/CapturingLogger.cs
Normal file
24
services/domain/Big.Tests/CapturingLogger.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Big.Tests;
|
||||
|
||||
/// <summary>A minimal <see cref="ILogger{T}"/> that records the level and rendered message of each
|
||||
/// entry, so tests can assert that (for example) a failing job was logged.</summary>
|
||||
internal sealed class CapturingLogger<T> : ILogger<T>
|
||||
{
|
||||
public List<(LogLevel Level, string Message)> Entries { get; } = [];
|
||||
|
||||
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
|
||||
Func<TState, Exception?, string> formatter)
|
||||
=> Entries.Add((logLevel, formatter(state, exception)));
|
||||
|
||||
private sealed class NullScope : IDisposable
|
||||
{
|
||||
public static readonly NullScope Instance = new();
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
@@ -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<InvalidOperationException>(() => 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<HttpRequestException>(
|
||||
() => 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<HttpRequestException>(
|
||||
() => client.CompleteOpenZaakJobAsync("job-1", new Uri("http://openzaak/zaken/api/v1/zaken/abc")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ArgumentNullException>(() => store.SaveAsync(null!));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<OpenZaakJobProcessor>();
|
||||
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]
|
||||
|
||||
@@ -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<ArgumentNullException>(() => worker.HandleAsync(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -36,8 +45,9 @@ public class OpenZaakWorkerTests
|
||||
var acl = new FakeAclClient();
|
||||
var worker = new OpenZaakWorker(store, acl);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => worker.HandleAsync(new OpenZaakJob("job-1", RegistrationId.New())));
|
||||
var job = new OpenZaakJob("job-1", RegistrationId.New());
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => worker.HandleAsync(job));
|
||||
Assert.Contains(job.RegistrationId.ToString(), ex.Message);
|
||||
Assert.Equal(0, acl.CallCount);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ArgumentException>(() => 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<InvalidOperationException>(
|
||||
var ex = Assert.Throws<InvalidOperationException>(
|
||||
() => registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/other")));
|
||||
Assert.Contains("/zaken/abc", ex.Message);
|
||||
Assert.Contains("/zaken/other", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ArgumentNullException>(() => handler.HandleAsync(null!));
|
||||
Assert.Equal(0, store.SaveCount);
|
||||
Assert.Null(workflow.StartedFor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
15
services/domain/stryker-config.json
Normal file
15
services/domain/stryker-config.json
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user