test(events): integration tests for EventDeployService#814
Conversation
Adds NUnit integration tests covering the inline-deploy invariants: empty window no-op, past-rotation skip, IsFromCompliance skip, idempotence, Planning state not mutated, per-rotation failure isolation, cancellation. Catches regressions in the eager-deploy pipeline added in d9abf12 before they reach master. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Idempotence test now captures logs via a TestLogger<T> and asserts the planning-not-found / areaRulePlanning-not-found warnings were NOT emitted. The previous Count==1 assertion was tautological because a broken guard would still produce count==1 via the AreaRulePlanning null-fallthrough at EventDeployService.cs:227. Production has no "already deployed" log on the guard path, so the test pins the invariant negatively (no downstream warning) instead of positively matching a non-existent log line; this still cleanly distinguishes "guard fired" from "guard missed". - Cancellation test now uses Assert.ThrowsAsync<OperationCanceledException> instead of a swallowing try/catch. The throw is guaranteed by the EF FirstOrDefaultAsync(..., cancellationToken) call at line 145; swallowing it would hide a regression that drops the token. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pulls the 5-way duplicated mock construction and 4-way rotation-DTO shape into private helpers, plus drops the redundant non-null assertions on GetCore() which returns Task<Core> (never nullable). No behavioural change in any test; assertions and arrangement remain byte-equivalent. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds a new NUnit integration test fixture, EventDeployServiceTest, that pins five inline-deploy invariants of EventDeployService (introduced in PR #813). The fixture mocks IBackendConfigurationCalendarService and IEFormCoreService to drive the no-op fast paths (no rotations, past rotation, IsFromCompliance=true), seeds a real Compliance row to verify the idempotence guard via a captured-log TestLogger, and asserts that a pre-cancelled CancellationToken propagates as OperationCanceledException with no Compliance writes. Three additional cases (planning-state immutability, per-rotation failure isolation, happy-path E2E) are intentionally deferred with in-file rationale.
Changes:
- New
EventDeployServiceTestfixture with 5 active tests + 3 documented skips - Helpers
MakeMocks/MakeService/MakeRotationto reduce per-test boilerplate - Internal
TestLogger<T>capture-onlyILoggerto support log-based guard assertions
Comments suppressed due to low confidence (2)
eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn.Integration.Test/EventDeployServiceTest.cs:336
- Test 7's docstring states the EF query at
EventDeployService.cs:145is "GUARANTEED to throw OperationCanceledException". This is misleading: even if EF Core were to ignore the cancellation token at that query, the explicitcancellationToken.ThrowIfCancellationRequested()inside the foreach loop (currentlyEventDeployService.cs:170) will also throw on the first candidate iteration. The test would still pass via that path, so the comment overstates which line is actually pinning the invariant — and a future regression that drops the token at line 145 (but keeps it at line 170) would not be caught by this test.
// Act + Assert — the future-day rotation passes the candidate
// filter, so the pipeline reaches the SDK Sites EF query at
// EventDeployService.cs:145 which is passed the cancelled token
// and is GUARANTEED to throw OperationCanceledException (or its
// TaskCanceledException subclass). A previous swallowing try/catch
// would have masked a regression that accidentally drops the token
// (e.g. passes CancellationToken.None to the EF call).
Assert.ThrowsAsync<OperationCanceledException>(
() => service.EnsureDeployedAsync(
PropertyId, BoardIds, "2026-05-14", "2026-05-20", SdkSiteId, cts.Token));
eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn.Integration.Test/EventDeployServiceTest.cs:336
Assert.ThrowsAsyncruns the supplied async lambda synchronously (via blocking on the returned task) and is not awaited here. Inside anasync Tasktest, the conventional pattern isawait Assert.ThrowsAsync<OperationCanceledException>(...)so the exception is observed on the test's async context; the current call works in NUnit but is inconsistent with the rest of the test method (which usesawaitfor every other awaitable) and may also surface analyzer warnings (CA2007 / unawaited task). Addingawaitkeeps the style uniform and avoids any surprise if NUnit ever changes howThrowsAsyncschedules the lambda.
Assert.ThrowsAsync<OperationCanceledException>(
() => service.EnsureDeployedAsync(
PropertyId, BoardIds, "2026-05-14", "2026-05-20", SdkSiteId, cts.Token));
| /// Integration tests for <see cref="EventDeployService"/> — the inline-deploy | ||
| /// pipeline that backs <c>EventsGrpcService.ListEvents</c>. Each test pins one | ||
| /// invariant from <c>EventDeployService.cs:42-50</c> so a future regression in | ||
| /// the eager-deploy contract surfaces in CI before reaching master. | ||
| /// | ||
| /// All tests use a real <see cref="BackendConfigurationPnDbContext"/> + | ||
| /// <see cref="ItemsPlanningPnDbContext"/> from <see cref="TestBaseSetup"/> and | ||
| /// mock <see cref="IBackendConfigurationCalendarService"/> so we can pin the | ||
| /// rotation stream the deploy pipeline iterates over without standing up the | ||
| /// full calendar service. <see cref="IEFormCoreService"/> is also mocked | ||
| /// because the no-op fast paths (tests 1-3) return before | ||
| /// <c>coreHelper.GetCore()</c> is reached; tests that DO reach the SDK path | ||
| /// (4, 7) seed a real SDK site via <see cref="TestBaseSetup.GetCore"/>. | ||
| /// </summary> |
|
|
||
| // Act | ||
| await service.EnsureDeployedAsync( | ||
| PropertyId, BoardIds, "2026-05-14", "2026-05-20", SdkSiteId, CancellationToken.None); |
| Is.False, | ||
| "Guard should have short-circuited before the planning lookup at EventDeployService.cs:200-212."); | ||
| Assert.That( | ||
| logger.Entries.Any(e => e.Message.Contains("areaRulePlanning") && e.Message.Contains("not found")), |
Summary
Adds NUnit integration tests covering the inline-deploy invariants of
EventDeployService(added in PR #813). Catches regressions before they reach master.Tests added (5)
EnsureDeployedAsync_NoRotationsInWindow_DoesNothing— empty calendar result, no Compliance writes, SDK core never reached.EnsureDeployedAsync_RotationInPast_SkippedNotDeployed— yesterday-dated rotation filtered before the deploy path.EnsureDeployedAsync_RotationIsFromCompliance_SkippedNotDeployed—IsFromCompliance=truerotation filtered by the candidate.Where.EnsureDeployedAsync_ComplianceAlreadyExists_SkipsRotation— seeds a Compliance for(planningId, deadline.Date), asserts the idempotence guard prevents a duplicate. Uses a custom TestLogger to also pin that the post-guard not-found warning paths are NOT taken — without this, a broken guard would silently produce the same Compliance count via theAreaRulePlanning == nullfallthrough.EnsureDeployedAsync_CancellationRequested_HonoursToken— pre-cancelled token, assertsOperationCanceledExceptionis thrown and no Compliance rows are written.Tests deferred (with justifications in code)
Planning + PlanningNameTranslation + AreaRulePlanning + AreaRule + Area + Propertygraph + a realCaseCreateagainst a real eForm template. Invariant is structurally enforced by the source (no writes toLastExecutedTime/DoneInPeriod/NextExecutionTime/PushMessageSentanywhere) and was pinned by the pre-push code-reviewer on PR feat(events): eager-deploy future events inline in ListEvents #813.eFormCore.Coreis a concrete class with no interface, so we can't makeCaseCreatethrow selectively. Follow-up: extract anICoreFacadeor similar.Pre-push gate
3814d58d:Assert.ThrowsAsync<OperationCanceledException>.CI
These tests run as part of the existing
test-dotnetjob in.github/workflows/dotnet-core-master.yml, which spins up MariaDB via Testcontainers.🤖 Generated with Claude Code