## What & why
S-17: a BIG inscription is valid for a fixed term; before it lapses the zorgprofessional must herregistreren. This adds a **daily herregistratie reminder sweep**.
- **Domain:** `Approve(ingeschrevenOp)` now stamps the inscription moment; `HerregistratieVoor` derives the deadline (inscription + 5-year validity); `HerregistratieReminderDue(asOf)` is the single rule (inside the 90-day window, inscribed, not yet reminded); `MarkHerregistratieReminderVerstuurd()` is idempotent.
- **Store:** `FindDueForHerregistratieReminderAsync(asOf)` — the sweep's candidate set, filtered on the aggregate's own rule (no duplicated policy).
- **Application:** `HerregistratieReminderSweep` — pure over the store + an injected `TimeProvider`; flags + persists each due inscription, returns the reminded ids.
- **Infra/API:** `HerregistratieReminderJob` (Quartz `IJob`) fires the sweep on a daily cron (03:00, overridable via `Quartz__Cron`) and logs the count. `GET /registrations/{id}` surfaces `herregistratieVoor` + `herregistratieReminderVerstuurd`.
**Decisions (both raised with you before coding):** use Quartz.NET as the PRD names it — a genuine cron concern, distinct from the queue-draining pumps, which stay as-is (**ADR-0022**, proposal #120); and the reminder's observable effect is a flag on the aggregate + a log line (no outbound notification infra in v1). No coupling rule (§8) is touched — Quartz is internal to the Domain Service.
Closes #18
Closes #120
## Definition of Done
- [x] Linked Gitea issue (above).
- [x] Failing test committed before the implementation (red→green per layer: domain rule, store query, sweep).
- [x] Implementation makes the test pass; refactor commit for the 90-day knob.
- [x] Conventional Commits referencing the issue (`refs #18`).
- [ ] CI green — awaiting Gitea Actions.
- [ ] `docker compose up` reaches green health checks within 3 minutes — API boots locally with Quartz initialised; verified in CI compose smoke.
- [x] Docs updated — ADR-0022, demo-script, BACKLOG.
- [x] ADR added — `docs/architecture/adr-0022-quartz-scheduler.md`.
- [x] Demo note in `docs/demo-script.md`.
## Notes for reviewers
- **Ripple:** `Approve()` gained the inscription moment, so the two approving handlers (`ApproveRegistration`, `BeoordeelRegistratie`) now take an injected `TimeProvider`; existing tests pass a fixed clock. All three `IRegistrationStore` implementers (prod, unit fake, acceptance) got the new query.
- **Calibration knobs:** validity (5y) and reminder lead time (90d) are domain constants marked with `ponytail:` comments; promotion path to beheer config (S-15) noted in the ADR.
- **Mutation:** the Quartz job shell is excluded from Stryker, mirroring the pumps; all rule/sweep/query logic is covered.
- Local: 152 domain unit tests green; API boots with the Quartz scheduler and `/health` green.
Reviewed-on: #121
149 lines
6.0 KiB
C#
149 lines
6.0 KiB
C#
using Big.Application;
|
|
using Big.Domain;
|
|
|
|
namespace Big.Tests;
|
|
|
|
/// <summary>An in-memory <see cref="IRegistrationStore"/> for the application-layer tests. Upserts
|
|
/// keyed on the registration id, mirroring the production store (kept distinct from the production
|
|
/// <c>InMemoryRegistrationStore</c> so tests can seed and inspect save counts).</summary>
|
|
internal sealed class FakeRegistrationStore : IRegistrationStore
|
|
{
|
|
private readonly Dictionary<RegistrationId, Registration> _byId = [];
|
|
|
|
public int SaveCount { get; private set; }
|
|
|
|
public Task SaveAsync(Registration registration, CancellationToken ct = default)
|
|
{
|
|
SaveCount++;
|
|
_byId[registration.Id] = registration;
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public Task<Registration?> GetAsync(RegistrationId id, CancellationToken ct = default)
|
|
=> Task.FromResult(_byId.GetValueOrDefault(id));
|
|
|
|
public Task<Registration?> FindOpenByBsnAsync(string bsn, CancellationToken ct = default)
|
|
=> Task.FromResult(_byId.Values.FirstOrDefault(r =>
|
|
r.Bsn == bsn && r.Status is RegistrationStatus.Ingediend or RegistrationStatus.InBehandeling));
|
|
|
|
public Task<IReadOnlyList<Registration>> FindDueForHerregistratieReminderAsync(
|
|
DateTimeOffset asOf, CancellationToken ct = default)
|
|
=> Task.FromResult<IReadOnlyList<Registration>>(
|
|
_byId.Values.Where(r => r.HerregistratieReminderDue(asOf)).ToList());
|
|
|
|
public void Seed(Registration registration) => _byId[registration.Id] = registration;
|
|
}
|
|
|
|
/// <summary>A fake Workflow Client that records the registration it was asked to start a process for
|
|
/// and returns a fixed process-instance id. An optional callback runs at start time, letting a test
|
|
/// assert ordering (e.g. that the registration was persisted before the process started).</summary>
|
|
internal sealed class FakeWorkflowClient(string processInstanceId = "proc-1", Action<RegistrationId>? onStart = null)
|
|
: IWorkflowClient
|
|
{
|
|
public RegistrationId? StartedFor { get; private set; }
|
|
public DiplomaOrigin? StartedWithOrigin { get; private set; }
|
|
public string? WithdrawnProcessInstanceId { get; private set; }
|
|
public string? CompletedDocumentWaitFor { get; private set; }
|
|
|
|
public Task<string> StartRegistrationProcessAsync(
|
|
RegistrationId registrationId, DiplomaOrigin diplomaOrigin, CancellationToken ct = default)
|
|
{
|
|
onStart?.Invoke(registrationId);
|
|
StartedFor = registrationId;
|
|
StartedWithOrigin = diplomaOrigin;
|
|
return Task.FromResult(processInstanceId);
|
|
}
|
|
|
|
public Task WithdrawProcessAsync(string processInstanceId, CancellationToken ct = default)
|
|
{
|
|
WithdrawnProcessInstanceId = processInstanceId;
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public Task CompleteDocumentWaitAsync(string processInstanceId, CancellationToken ct = default)
|
|
{
|
|
CompletedDocumentWaitFor = processInstanceId;
|
|
return Task.CompletedTask;
|
|
}
|
|
}
|
|
|
|
/// <summary>A fake user-task client for the werkbak/decision use cases: returns a scripted set of
|
|
/// open beoordeling tasks and records claim/complete calls.</summary>
|
|
internal sealed class FakeUserTaskClient(IReadOnlyList<BeoordelingTask> open) : IUserTaskClient
|
|
{
|
|
public (string TaskId, string Behandelaar)? Claimed { get; private set; }
|
|
public (string TaskId, BeoordelingsBesluit Besluit)? Completed { get; private set; }
|
|
|
|
public Task<IReadOnlyList<BeoordelingTask>> GetOpenBeoordelingenAsync(CancellationToken ct = default)
|
|
=> Task.FromResult(open);
|
|
|
|
public Task ClaimAsync(string taskId, string behandelaar, CancellationToken ct = default)
|
|
{
|
|
Claimed = (taskId, behandelaar);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public Task CompleteBeoordelingAsync(string taskId, BeoordelingsBesluit besluit, CancellationToken ct = default)
|
|
{
|
|
Completed = (taskId, besluit);
|
|
return Task.CompletedTask;
|
|
}
|
|
}
|
|
|
|
/// <summary>A <see cref="TimeProvider"/> pinned to a fixed instant, so time-based use cases (the
|
|
/// herregistratie sweep, S-17) are deterministic without the TimeProvider.Testing package.</summary>
|
|
internal sealed class FixedClock(DateTimeOffset now) : TimeProvider
|
|
{
|
|
public override DateTimeOffset GetUtcNow() => now;
|
|
}
|
|
|
|
/// <summary>A fake ACL client that records the bsn it was asked to open a zaak for and returns a
|
|
/// fixed zaak URL.</summary>
|
|
internal sealed class FakeAclClient(Uri? zaakUrl = null) : IAclClient
|
|
{
|
|
public static readonly Uri DefaultZaakUrl = new("http://openzaak/zaken/api/v1/zaken/abc");
|
|
|
|
private readonly Uri _zaakUrl = zaakUrl ?? DefaultZaakUrl;
|
|
|
|
public string? OpenedForBsn { get; private set; }
|
|
public string? OpenedWithReference { get; private set; }
|
|
public int CallCount { get; private set; }
|
|
|
|
public Uri? ApprovedZaakUrl { get; private set; }
|
|
public int ApproveCallCount { get; private set; }
|
|
|
|
public Task<Uri> OpenZaakAsync(string bsn, string reference, CancellationToken ct = default)
|
|
{
|
|
CallCount++;
|
|
OpenedForBsn = bsn;
|
|
OpenedWithReference = reference;
|
|
return Task.FromResult(_zaakUrl);
|
|
}
|
|
|
|
public Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default)
|
|
{
|
|
ApproveCallCount++;
|
|
ApprovedZaakUrl = zaakUrl;
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public (Uri ZaakUrl, byte[] Content, string FileName, string ContentType)? StoredDiploma { get; private set; }
|
|
public static readonly Uri DefaultDocumentUrl = new("http://openzaak/documenten/api/v1/enkelvoudiginformatieobjecten/doc");
|
|
|
|
public Task<Uri> StoreDiplomaAsync(Uri zaakUrl, byte[] content, string fileName, string contentType, CancellationToken ct = default)
|
|
{
|
|
StoredDiploma = (zaakUrl, content, fileName, contentType);
|
|
return Task.FromResult(DefaultDocumentUrl);
|
|
}
|
|
|
|
public Uri? CancelledZaakUrl { get; private set; }
|
|
public int CancelCallCount { get; private set; }
|
|
|
|
public Task CancelZaakAsync(Uri zaakUrl, CancellationToken ct = default)
|
|
{
|
|
CancelCallCount++;
|
|
CancelledZaakUrl = zaakUrl;
|
|
return Task.CompletedTask;
|
|
}
|
|
}
|