Skip to content
bitzorcas
中EN

Guide

Notifications testing, operations, and GA gates

Inventory current tests and define missing contracts, observability, capacity, reconciliation, incident response, and commercial-release blockers for Notifications.

Last updated

Notification success is not a 200 from Create. The intended user must receive correct, unique, safe, traceable content through allowed channels, and failures must be explainable, retryable, and reconcilable. The source has useful aggregate/persistence foundations but not yet the delivery/template loop needed for that SLO.

1. Current test assets

The repository contains:

  • NotificationAggregate state and restoration tests;
  • NotificationPreference and ReadMarker unit tests;
  • notification/template read-query handler tests;
  • TemplateSecurityChecker, VariableAnalyzer, NamingConverter, and StorageProjector tests;
  • NotificationArchivePagination tests;
  • dual-ORM parity for Notification/Template repositories and read models;
  • Notifications Infrastructure architecture and production-adapter readiness tests;
  • Workflow notification adapter/dispatcher tests;
  • template-migration tenant-backfill parity.

There is no direct coverage of NotificationService.CreateAsync, NotificationCreatedConsumer, MultiChannelDeliveryAdapter, NotificationOrchestrator, TemplateAppService, TemplateRendererService, ScribanTemplateCompiler, the 19 HTTP contracts, or provider failure recovery.

2. Test layers

E2E
intent → inbox → CAP → provider fake → receipt

Contract/integration
HTTP / auth / tenant / outbox / ORM / connector

Unit
state / preference / version / render / sanitizer

Unit tests fix pure rules, contract tests fix boundary/failure semantics, and E2E uses deterministic provider fakes for one complete delivery. A real-provider sandbox smoke does not replace deterministic contracts.

3. Create/delivery red test

External delivery remains recoverable when Inbox is disabled
[Fact]
public async Task Create_Should_Persist_Delivery_Fact_When_Inbox_Is_Disabled()
{
// The user hides Inbox but permits email; a logical fact must remain reloadable.
preferences.Set("tenant-a", "user-1", "security.login", new(
InboxEnabled: false,
ExternalDeliveryEnabled: true));
var created = await service.CreateAsync(
"tenant-a", "user-1", "security.login",
"New device sign-in", "A new device signed in",
severity: NotificationSeverity.Warning,
metadataJson: "{\"email\":\"user@example.com\"}");
created.IsSuccess.ShouldBeTrue();
// The current implementation did not save it, so this is an intentional red assertion.
(await repository.FindByIdAsync(created.Value!.NotificationId, default))
.IsSuccess.ShouldBeTrue();
}

Also prove atomic Save+outbox, one row per BusinessId, target-tenant consumer reload, one attempt per channel, and retry/dead state instead of swallowed failures.

4. Template red test

Render in the active tenant and preserve unsafe-content rejection
[Fact]
public async Task Render_Should_Resolve_Tenant_Template_And_Reject_Unsafe_Content()
{
// A tenant-owned template exposes the current empty-TenantId lookup defect.
await templates.SeedAsync(
tenantId: "tenant-a",
key: "Tickets.Assigned.Email.zh-CN",
body: "<script>alert(1)</script>{{ user_name }}");
var validation = await renderer.ValidateTemplateAsync(
"title", "<script>alert(1)</script>{{ user_name }}",
TemplateEngine.Scriban,
new Dictionary<string, object> { ["user_name"] = "Alice" });
validation.Value!.IsValid.ShouldBeFalse();
// The target facade carries tenant; current RenderAsync hard-codes an empty tenant.
var rendered = await facade.RenderAsync(
"tenant-a", "Tickets.Assigned.Email.zh-CN", ModuleCaller.Notification,
new Dictionary<string, object> { ["user_name"] = "Alice" });
rendered.IsFailure.ShouldBeTrue();
}

Version tests must include 1.0.9/1.0.10/1.0.11, concurrent updates, Description, locale uniqueness, import/export fidelity, write-time safety validation, and Preview report preservation.

5. HTTP and authorization contracts

For every generated route, verify 401, wrong-permission 403, correct-permission handler entry, route parameters, JSON, Problem Details, timeout, and rate-limit policy. High-risk drift:

  • POST /api/notifications asks for Create, but notification.create is absent;
  • Activate uses template.update, not template.activate;
  • Render/Preview/Validate use template.view while template.preview is unused;
  • current-user APIs cannot choose recipient;
  • Read/Unread/Archive return the same NotFound for another user’s id.

Do not merely inspect attributes. Exercise the real catalog seed and authorization middleware.

6. Observability

Business metrics

  • notification_intent_total / deduplicated_total;
  • inbox_projection_total / hidden_by_preference;
  • delivery_attempt_total{channel,result,provider};
  • delivery_latency, queue_age, retry_count, dead_count;
  • template_resolve{scope,locale,channel,result};
  • render_latency, compile_cache_hit, validation_reject, degraded_total;
  • unread_count and inbox-query latency;
  • hot/archive rows, archive lag, and reconciliation_delta.

Minimal log fields

EventId, NotificationId, tenant_hash, code, channel, attempt, provider, error_code, and traceId. Never log Body, template variables, MetadataJson, Email, Phone, or raw UserId. Sanitize vendor exceptions, which often contain recipient data.

7. SLOs and alerts

Example targets require product approval:

SLIExample target
intent→inbox P99<2s
intent→provider accepted P95<60s
24h final delivery success≥99.9%, excluding permanent address errors
duplicate delivered<1 ppm
template resolve/render success≥99.99%
oldest queue age<5 min

Alerts must derive from attempt state and queue age, not log searches alone. The absence of an attempt ledger prevents reliable measurement and is a GA blocker.

8. Incident runbook

SignalFirst diagnosisResponse
CAP event but no rowInbox=false or absent tenant contextpause consumer, reconcile event tenant/database, replay after fix
one channel always skipsport unregistered/contact absentinspect config/secret without logging values
provider 429/5xxthrottle/outagereduce concurrency, honor Retry-After, preserve attempts
duplicate SMS/emailmissing idempotency or unknown timeout retrystop replay; reconcile provider message ids
template NotFound surgeempty tenant/fallback defectroll back caller; do not copy templates as a workaround
Preview green but XSS in productionvalidation discarded/output unsafedisable template, switch safe fallback, inspect history
unread badge mismatcharchived unread/count only hotreconcile hot/archive and fix counting policy

Without persisted attempts, “replay failed notifications” is unsafe. Never bulk-call providers from log entries; the original result may have been unknown rather than failed.

9. Capacity and recovery

Test intent throughput, a hot tenant, 100,000 unread for one user, MarkAllRead, deep hot/archive pages, 10/100/10,000 templates, 1/100/1,000 variables, body size, SMS segments, provider timeout, and CAP backlog.

Backup/restore must cover SysNotification, Archive, Preference, all three template tables, and CAP outbox/inbox—plus DeliveryAttempt after it exists. Reconcile by id, business key, version, and hashes, and prove delivered attempts are not resent.

SQL Server notification archival is provider-specific. EF Core read parity or non-SQL Server operation does not prove physical archival; publish an explicit support matrix.

10. GA blockers

  • Create permission matches catalog; Activate/Preview use dedicated permissions or remove misleading codes.
  • HTTP/background paths use one EffectiveTenant and explicit RequireUserId semantics.
  • NotificationIntent has a business idempotency key and atomic Save+outbox.
  • Inbox=false does not break external delivery; mandatory-notice policy exists.
  • Consumer establishes tenant context, does not swallow failure, and supports retry/dead/replay.
  • Per-channel DeliveryAttempt has uniqueness, status, ProviderMessageId, and reconciliation.
  • EnabledChannels works and contact PII is not retained indefinitely in Inbox metadata.
  • Template resolution supports tenant/PLATFORM/locale/channel.
  • Only implemented engines are accepted; CallerModule is server-authorized.
  • Create/Update require compile, schema, security review, and publication approval.
  • Preview preserves validation; render failures never return raw template source.
  • HTML uses context encoding and a mature allowlist sanitizer.
  • Numeric versioning, uniqueness, concurrency, and Description are fixed.
  • Bilingual seeds, 100+ export paging, and import/export fidelity are fixed.
  • HTTP, dual-ORM, CAP, provider, capacity, archive, and restore suites pass.

11. Verification commands

Terminal window
# Current module test assets.
dotnet test tests/BitzOrcas.Unit.Tests/BitzOrcas.Unit.Tests.csproj \
--filter 'FullyQualifiedName~Notification|FullyQualifiedName~Template'
dotnet test tests/BitzOrcas.Application.Tests/BitzOrcas.Application.Tests.csproj \
--filter 'FullyQualifiedName~Notifications'
dotnet test tests/BitzOrcas.Architecture.Tests/BitzOrcas.Architecture.Tests.csproj \
--filter 'FullyQualifiedName~NotificationsInfrastructureArchitectureTests'
# Sweep current critical boundaries.
rg -n 'skipExternalDelivery|EnabledChannels|GetByKeyAsync\(\s*string.Empty' \
src/Platform/Notifications -g '*.cs'
rg -n 'DeliveryAttempt|ProviderMessageId|DeduplicationKey|ModuleAuthorization' \
src/Platform/Notifications -g '*.cs'

Back to Notifications

100%

Scroll or use controls to zoom · drag when enlarged · double-click for 100% / 200%