test(domain): Workflow Client, ACL client, store and job processor (refs #6)

Failing infrastructure unit tests (stub HttpMessageHandler, fakes):
- FlowableWorkflowClient starts a process with the registrationId variable and
  returns the instance id; acquires OpenZaakAanmaken jobs (topic/workerId/lock)
  and parses their registrationId; completes a job with the zaakUrl variable —
  request URIs match flowable-rest's service/ and external-job-api/ paths.
- AclHttpClient POSTs the bsn to the ACL and returns the zaak URL.
- InMemoryRegistrationStore saves/reads/upserts by id.
- OpenZaakJobProcessor acquires, opens a zaak, completes the job; leaves a failing
  job uncompleted for redelivery; polls harmlessly when idle.

Adapters are stubs so the tests compile and fail on their assertions; the green
commit implements them against the REST contract verified on a live Flowable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-30 17:08:56 +02:00
parent 39b2388a9d
commit 6d4adaf957
18 changed files with 463 additions and 8 deletions

View File

@@ -0,0 +1,17 @@
using Big.Application;
namespace Big.Infrastructure;
/// <summary>
/// HTTP client to the ACL service — the boundary the domain crosses to open a zaak (§8.1). It POSTs
/// the bsn to the ACL's <c>/zaken</c> endpoint and returns the created zaak URL; it never constructs
/// ZGW URLs or talks to OpenZaak itself.
/// </summary>
public sealed class AclHttpClient(HttpClient http, AclOptions options) : IAclClient
{
public Task<Uri> OpenZaakAsync(string bsn, CancellationToken ct = default)
{
// STUB (red).
return Task.FromResult(new Uri("about:blank"));
}
}

View File

@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- The infrastructure layer (CLAUDE.md §9): adapters implementing the Application ports.
The Workflow Client (Flowable REST) and the ACL HTTP client live here — the only code
that talks to Flowable (§8.2) and to the ACL respectively. -->
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Big.Application\Big.Application.csproj" />
</ItemGroup>
<ItemGroup>
<!-- The external-task job worker is a hosted BackgroundService (ADR-0009); it logs and
resolves a per-tick scope. Abstractions only — the host (Api) brings the implementations. -->
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,31 @@
using Big.Application;
using Big.Domain;
namespace Big.Infrastructure;
/// <summary>
/// The Workflow Client — the only code that talks to Flowable (§8.2). It starts registratie process
/// instances and drives the <c>OpenZaakAanmaken</c> external-worker jobs over Flowable's REST API
/// (start: <c>service/runtime/process-instances</c>; acquire/complete: <c>external-job-api/…</c>).
/// </summary>
public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions options)
: IWorkflowClient, IExternalWorkerClient
{
public Task<string> StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default)
{
// STUB (red).
return Task.FromResult("");
}
public Task<IReadOnlyList<OpenZaakJob>> AcquireOpenZaakJobsAsync(int maxJobs, CancellationToken ct = default)
{
// STUB (red).
return Task.FromResult<IReadOnlyList<OpenZaakJob>>([]);
}
public Task CompleteOpenZaakJobAsync(string jobId, Uri zaakUrl, CancellationToken ct = default)
{
// STUB (red).
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,18 @@
using Big.Application;
namespace Big.Infrastructure;
/// <summary>
/// The worker-facing side of the Workflow Client: acquiring and completing Flowable external-worker
/// jobs (ADR-0009). Kept separate from the Application's <see cref="IWorkflowClient"/> because job
/// acquisition is a Flowable-specific polling mechanic the application never needs to know about.
/// Implemented by <see cref="FlowableWorkflowClient"/> — the only code that talks to Flowable (§8.2).
/// </summary>
public interface IExternalWorkerClient
{
/// <summary>Acquire and lock up to <paramref name="maxJobs"/> <c>OpenZaakAanmaken</c> jobs.</summary>
Task<IReadOnlyList<OpenZaakJob>> AcquireOpenZaakJobsAsync(int maxJobs, CancellationToken ct = default);
/// <summary>Complete an acquired job, passing the opened zaak URL back into the process.</summary>
Task CompleteOpenZaakJobAsync(string jobId, Uri zaakUrl, CancellationToken ct = default);
}

View File

@@ -0,0 +1,27 @@
using System.Collections.Concurrent;
using Big.Application;
using Big.Domain;
namespace Big.Infrastructure;
/// <summary>
/// In-memory <see cref="IRegistrationStore"/> for the minimal slice (ADR-0009). The walking
/// skeleton's read path is the projection (S-06), not this store, so durable domain persistence is a
/// documented follow-up. Registered as a singleton so the submit endpoint and the worker share it.
/// </summary>
public sealed class InMemoryRegistrationStore : IRegistrationStore
{
private readonly ConcurrentDictionary<RegistrationId, Registration> _byId = new();
public Task SaveAsync(Registration registration, CancellationToken ct = default)
{
// STUB (red).
return Task.CompletedTask;
}
public Task<Registration?> GetAsync(RegistrationId id, CancellationToken ct = default)
{
// STUB (red).
return Task.FromResult<Registration?>(null);
}
}

View File

@@ -0,0 +1,26 @@
using Big.Application;
using Microsoft.Extensions.Logging;
namespace Big.Infrastructure;
/// <summary>
/// One poll tick of the external-task worker (ADR-0009): acquire the parked <c>OpenZaakAanmaken</c>
/// jobs, hand each to the <see cref="OpenZaakWorker"/> to open a zaak via the ACL, and complete the
/// Flowable job with the resulting zaak URL. A job that fails is logged and left un-completed so
/// Flowable redelivers it (§8.6). Split out from the hosted <see cref="OpenZaakJobPump"/> so the
/// acquire→process→complete logic is unit-testable without a running host.
/// </summary>
public sealed class OpenZaakJobProcessor(
IExternalWorkerClient client,
OpenZaakWorker worker,
ILogger<OpenZaakJobProcessor> logger)
{
/// <summary>Process up to <paramref name="maxJobs"/> jobs. Returns the number completed.</summary>
public async Task<int> PumpOnceAsync(int maxJobs, CancellationToken ct = default)
{
// STUB (red).
await Task.CompletedTask;
_ = logger;
return 0;
}
}

View File

@@ -0,0 +1,29 @@
namespace Big.Infrastructure;
/// <summary>Configuration for the Flowable Workflow Client. <see cref="BaseUrl"/> is the flowable-rest
/// root and must end with a slash (e.g. <c>http://flowable-rest:8080/flowable-rest/</c>) so the
/// <c>service/…</c> and <c>external-job-api/…</c> sub-paths resolve correctly.</summary>
public sealed class FlowableOptions
{
public Uri BaseUrl { get; set; } = null!;
public string Username { get; set; } = "";
public string Password { get; set; } = "";
/// <summary>The id this worker locks jobs under, so two workers don't process the same job.</summary>
public string WorkerId { get; set; } = "big-domain-worker";
/// <summary>How long an acquired job stays locked to this worker (ISO-8601 duration).</summary>
public string LockDuration { get; set; } = "PT5M";
/// <summary>How many OpenZaakAanmaken jobs to acquire per poll.</summary>
public int MaxJobsPerPoll { get; set; } = 5;
/// <summary>How often the worker polls Flowable for new jobs.</summary>
public TimeSpan PollInterval { get; set; } = TimeSpan.FromSeconds(2);
}
/// <summary>Configuration for the ACL HTTP client. <see cref="BaseUrl"/> is the ACL service root.</summary>
public sealed class AclOptions
{
public Uri BaseUrl { get; set; } = null!;
}