merge: integrate notification production capability
# Conflicts: # docs/superpowers/plans/2026-07-28-notification-production-capability.md # src/adapter/outbound/persistence-jpa/build.gradle # src/adapter/outbound/persistence-jpa/gradle.lockfile # src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringTransactionPort.java # src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java # src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationCapabilityCompatibilityValidator.java # src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationDeliveryStorePort.java # src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationDispatchUseCase.java # src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationFrozenPlan.java # src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationMaintenanceCommand.java # src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationMaintenanceResult.java # src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationMaintenanceStorePort.java # src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationCapabilityCompatibilityValidatorTest.java # src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationDispatchUseCaseTest.java # src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationMaintenanceUseCaseTest.java # src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationPlanningBoundaryTest.java # src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationValueContractTest.java # src/application-core/src/test/java/dev/caskeleton/application/notification/ReconcileNotificationDeliveriesUseCaseTest.java
This commit is contained in:
@@ -74,6 +74,29 @@ Package root: `dev.caskeleton.application`.
|
||||
|
||||
## Notification R1 application boundary
|
||||
|
||||
- `dev.caskeleton.application.notification` owns only framework-free semantic values, code-owned
|
||||
kind policy, narrow outbound ports, dispatch/receipt/admission/reconciliation orchestration and
|
||||
writer-cutover command contracts.
|
||||
- Feature/application code creates a typed `NotificationIntentDraft`; `NotificationPlanPort`
|
||||
returns the application-owned immutable `NotificationFrozenPlan`, which is the only planning
|
||||
handoff consumed by append or inline attempt ports. Provider SDK, transport DTO, persistence
|
||||
entity, compiled adapter binding and raw recipient/template payload types are forbidden here.
|
||||
- Provider calls run outside database transactions. Dispatch and reconciliation use bounded
|
||||
claim/authorize/finalize transactions with opaque claim/version/execution tokens; an
|
||||
`INDETERMINATE` submission is terminal and must not be blindly retried.
|
||||
- Receipt reduction is order-independent and keeps delivery acceptance monotonic. Only hard bounce
|
||||
and complaint facts may request technical suppression; consent/unsubscribe policy is outside this
|
||||
capability.
|
||||
- Writer-cutover operations that must prove a physical commit use `inRootWrite`. Route/profile
|
||||
registries are application-owned exact inputs; signed inventory/quiescence verification is
|
||||
delegated to narrow verifier ports and the persistence operation must enforce locked durable
|
||||
state/journal invariants.
|
||||
- This is the R1 application contract proven with fakes. It does not claim PostgreSQL schema/locking,
|
||||
provider protocol, cryptographic verifier, or runtime wiring qualification; those belong to the
|
||||
notification/persistence/bootstrap adapters.
|
||||
|
||||
## Notification R1 application boundary
|
||||
|
||||
- `dev.caskeleton.application.notification` owns only framework-free semantic values, code-owned
|
||||
kind policy, narrow outbound ports, dispatch/receipt/admission/reconciliation orchestration and
|
||||
writer-cutover command contracts.
|
||||
|
||||
+4
@@ -27,6 +27,10 @@ public final class NotificationCapabilityCompatibilityValidator {
|
||||
reasons,
|
||||
provider.maximumTargets() < policy.maxTargetsPerRecipient(),
|
||||
"PROVIDER_TARGET_BOUND_INSUFFICIENT");
|
||||
addIf(
|
||||
reasons,
|
||||
policy.maxReconcileCalls() > 0 && !provider.reconciliationSupported(),
|
||||
"PROVIDER_RECONCILIATION_UNSUPPORTED");
|
||||
if (policy.mode() == NotificationMode.DURABLE_ASYNC) {
|
||||
addIf(
|
||||
reasons,
|
||||
|
||||
+69
-6
@@ -146,6 +146,20 @@ public interface NotificationDeliveryStorePort {
|
||||
&& providerOutcome.retryDisposition() != RetryDisposition.PARK_BINDING) {
|
||||
throw new IllegalArgumentException("PARKED_BINDING requires PARK_BINDING disposition");
|
||||
}
|
||||
if (providerOutcome.retryDisposition() == RetryDisposition.PARK_BINDING) {
|
||||
if (parkResult == NotificationAdmissionReadinessPort.ParkResult.NOT_REQUESTED) {
|
||||
throw new IllegalArgumentException("PARK_BINDING requires an admission park result");
|
||||
}
|
||||
boolean parked =
|
||||
parkResult == NotificationAdmissionReadinessPort.ParkResult.PARKED
|
||||
|| parkResult == NotificationAdmissionReadinessPort.ParkResult.ALREADY_PARKED;
|
||||
TerminalState expected =
|
||||
parked ? TerminalState.PARKED_BINDING : TerminalState.RETRY_SCHEDULED;
|
||||
if (terminalState != expected) {
|
||||
throw new IllegalArgumentException(
|
||||
"terminal state must reflect the generation-guarded admission park result");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,19 +182,46 @@ public interface NotificationDeliveryStorePort {
|
||||
NotificationDeliveryId deliveryId,
|
||||
String executionToken,
|
||||
long expectedRowVersion,
|
||||
String providerMessageReference,
|
||||
NotificationRouteId routeId,
|
||||
int routeRevision,
|
||||
String bindingDigest,
|
||||
int targetOrdinal,
|
||||
String targetReference,
|
||||
String providerCapabilityReference,
|
||||
String providerBindingRevision,
|
||||
String credentialGeneration,
|
||||
String lookupReference,
|
||||
ReconciliationLookupKind lookupKind,
|
||||
Instant absoluteDeadline) {
|
||||
|
||||
public ReconciliationClaim {
|
||||
Objects.requireNonNull(deliveryId, "notification delivery ID must be non-null");
|
||||
executionToken =
|
||||
NotificationIntentId.requireOpaque("reconciliation execution token", executionToken);
|
||||
if (expectedRowVersion < 0) {
|
||||
throw new IllegalArgumentException("reconciliation row version must be non-negative");
|
||||
if (expectedRowVersion < 0 || routeRevision < 1 || targetOrdinal < 0 || targetOrdinal > 15) {
|
||||
throw new IllegalArgumentException(
|
||||
"reconciliation row version, route revision and target ordinal are invalid");
|
||||
}
|
||||
providerMessageReference =
|
||||
Objects.requireNonNull(routeId, "reconciliation route ID must be non-null");
|
||||
if (bindingDigest == null || !bindingDigest.matches("[0-9a-f]{64}")) {
|
||||
throw new IllegalArgumentException(
|
||||
"reconciliation binding digest must be a lowercase SHA-256 digest");
|
||||
}
|
||||
targetReference =
|
||||
NotificationIntentId.requireOpaque("reconciliation target reference", targetReference);
|
||||
providerCapabilityReference =
|
||||
NotificationIntentId.requireOpaque(
|
||||
"provider message reference", providerMessageReference);
|
||||
"reconciliation provider capability reference", providerCapabilityReference);
|
||||
providerBindingRevision =
|
||||
NotificationIntentId.requireOpaque(
|
||||
"reconciliation provider binding revision", providerBindingRevision);
|
||||
credentialGeneration =
|
||||
NotificationIntentId.requireSlug(
|
||||
"reconciliation credential generation", credentialGeneration);
|
||||
lookupReference =
|
||||
NotificationIntentId.requireOpaque(
|
||||
"provider reconciliation lookup reference", lookupReference);
|
||||
Objects.requireNonNull(lookupKind, "provider reconciliation lookup kind must be non-null");
|
||||
Objects.requireNonNull(absoluteDeadline, "reconciliation deadline must be non-null");
|
||||
}
|
||||
|
||||
@@ -189,12 +230,34 @@ public interface NotificationDeliveryStorePort {
|
||||
return "ReconciliationClaim[deliveryId=<redacted>, executionToken=<redacted>, "
|
||||
+ "expectedRowVersion="
|
||||
+ expectedRowVersion
|
||||
+ ", providerMessageReference=<redacted>, absoluteDeadline="
|
||||
+ ", routeId="
|
||||
+ routeId
|
||||
+ ", routeRevision="
|
||||
+ routeRevision
|
||||
+ ", bindingDigest="
|
||||
+ bindingDigest
|
||||
+ ", targetOrdinal="
|
||||
+ targetOrdinal
|
||||
+ ", targetReference=<redacted>, providerCapabilityReference="
|
||||
+ providerCapabilityReference
|
||||
+ ", providerBindingRevision="
|
||||
+ providerBindingRevision
|
||||
+ ", credentialGeneration="
|
||||
+ credentialGeneration
|
||||
+ ", lookupReference=<redacted>, lookupKind="
|
||||
+ lookupKind
|
||||
+ ", absoluteDeadline="
|
||||
+ absoluteDeadline
|
||||
+ "]";
|
||||
}
|
||||
}
|
||||
|
||||
enum ReconciliationLookupKind {
|
||||
PRE_SEND_CORRELATION,
|
||||
CLIENT_OPERATION_KEY,
|
||||
MESSAGE_REFERENCE
|
||||
}
|
||||
|
||||
enum ReconciliationFinalizationResult {
|
||||
APPLIED,
|
||||
LATE_EXACT_APPLIED,
|
||||
|
||||
+11
-3
@@ -134,7 +134,7 @@ public final class NotificationDispatchUseCase
|
||||
|
||||
NotificationDeliveryStorePort.AttemptFinalization finalization =
|
||||
new NotificationDeliveryStorePort.AttemptFinalization(
|
||||
outcome, terminalState(outcome), fallbackEligible(outcome), parkResult);
|
||||
outcome, terminalState(outcome, parkResult), fallbackEligible(outcome), parkResult);
|
||||
NotificationDeliveryStorePort.FinalizationResult result =
|
||||
Objects.requireNonNull(
|
||||
store.finalizeAttempt(attempt, finalization, clock.instant()),
|
||||
@@ -143,7 +143,7 @@ public final class NotificationDispatchUseCase
|
||||
}
|
||||
|
||||
private static NotificationDeliveryStorePort.TerminalState terminalState(
|
||||
ProviderAttemptOutcome outcome) {
|
||||
ProviderAttemptOutcome outcome, NotificationAdmissionReadinessPort.ParkResult parkResult) {
|
||||
if (outcome.submissionCertainty() == SubmissionCertainty.PROVIDER_ACCEPTED) {
|
||||
return NotificationDeliveryStorePort.TerminalState.ACCEPTED;
|
||||
}
|
||||
@@ -152,7 +152,15 @@ public final class NotificationDispatchUseCase
|
||||
}
|
||||
return switch (outcome.retryDisposition()) {
|
||||
case RETRY_AT -> NotificationDeliveryStorePort.TerminalState.RETRY_SCHEDULED;
|
||||
case PARK_BINDING -> NotificationDeliveryStorePort.TerminalState.PARKED_BINDING;
|
||||
case PARK_BINDING ->
|
||||
switch (parkResult) {
|
||||
case PARKED, ALREADY_PARKED ->
|
||||
NotificationDeliveryStorePort.TerminalState.PARKED_BINDING;
|
||||
case STALE_GENERATION -> NotificationDeliveryStorePort.TerminalState.RETRY_SCHEDULED;
|
||||
case NOT_REQUESTED ->
|
||||
throw new IllegalStateException(
|
||||
"PARK_BINDING outcome requires an admission park result");
|
||||
};
|
||||
case TERMINAL -> NotificationDeliveryStorePort.TerminalState.TERMINAL_FAILURE;
|
||||
case NOT_APPLICABLE ->
|
||||
throw new IllegalArgumentException(
|
||||
|
||||
+132
-5
@@ -1,6 +1,10 @@
|
||||
package dev.caskeleton.application.notification;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
@@ -10,6 +14,7 @@ public record NotificationFrozenPlan(
|
||||
NotificationIntentId intentId,
|
||||
NotificationKindPolicy policy,
|
||||
Locale selectedLocale,
|
||||
BindingSnapshot binding,
|
||||
NotificationRecipientReference recipient,
|
||||
NotificationTemplateParameters parameters,
|
||||
String idempotencyScope,
|
||||
@@ -24,33 +29,44 @@ public record NotificationFrozenPlan(
|
||||
Objects.requireNonNull(intentId, "notification intent ID must be non-null");
|
||||
Objects.requireNonNull(policy, "notification kind policy must be non-null");
|
||||
selectedLocale = NotificationIntentDraft.requireLocale("selected locale", selectedLocale);
|
||||
Objects.requireNonNull(binding, "notification binding snapshot must be non-null");
|
||||
Objects.requireNonNull(recipient, "notification recipient must be non-null");
|
||||
Objects.requireNonNull(parameters, "notification template parameters must be non-null");
|
||||
idempotencyScope =
|
||||
NotificationIntentId.requireOpaque("notification idempotency scope", idempotencyScope);
|
||||
sourceOperationId =
|
||||
NotificationIntentId.requireOpaque("notification source operation ID", sourceOperationId);
|
||||
Objects.requireNonNull(tenantReference, "tenant reference container must be non-null");
|
||||
tenantReference = requireOptionalOpaque("tenant reference", tenantReference);
|
||||
correlationReference =
|
||||
NotificationIntentId.requireOpaque(
|
||||
"notification correlation reference", correlationReference);
|
||||
Objects.requireNonNull(causationReference, "causation reference container must be non-null");
|
||||
causationReference = requireOptionalOpaque("causation reference", causationReference);
|
||||
Objects.requireNonNull(notBefore, "notification not-before time must be non-null");
|
||||
Objects.requireNonNull(expiresAt, "notification expiry time must be non-null");
|
||||
if (recipient.channel() != policy.channel()) {
|
||||
throw new IllegalArgumentException("recipient channel must match notification kind channel");
|
||||
}
|
||||
if (!expiresAt.isAfter(notBefore)) {
|
||||
throw new IllegalArgumentException("notification expiry must be after not-before");
|
||||
if (binding.targets().size() != policy.maxTargetsPerRecipient()) {
|
||||
throw new IllegalArgumentException(
|
||||
"frozen binding target count must match the code-owned policy target bound");
|
||||
}
|
||||
Duration lifetime = Duration.between(notBefore, expiresAt);
|
||||
if (lifetime.isZero()
|
||||
|| lifetime.isNegative()
|
||||
|| lifetime.compareTo(policy.maxElapsedRetryHorizon()) > 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"notification expiry must be after not-before and within the policy retry horizon");
|
||||
}
|
||||
}
|
||||
|
||||
public static NotificationFrozenPlan from(NotificationIntentDraft draft, Locale selectedLocale) {
|
||||
public static NotificationFrozenPlan from(
|
||||
NotificationIntentDraft draft, Locale selectedLocale, BindingSnapshot binding) {
|
||||
Objects.requireNonNull(draft, "notification intent draft must be non-null");
|
||||
return new NotificationFrozenPlan(
|
||||
draft.intentId(),
|
||||
draft.policy(),
|
||||
selectedLocale,
|
||||
binding,
|
||||
draft.recipient(),
|
||||
draft.parameters(),
|
||||
draft.idempotencyScope(),
|
||||
@@ -70,6 +86,11 @@ public record NotificationFrozenPlan(
|
||||
return policy.routeId();
|
||||
}
|
||||
|
||||
private static Optional<String> requireOptionalOpaque(String field, Optional<String> reference) {
|
||||
Objects.requireNonNull(reference, field + " container must be non-null");
|
||||
return reference.map(value -> NotificationIntentId.requireOpaque(field, value));
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "NotificationFrozenPlan[intentId="
|
||||
@@ -80,10 +101,116 @@ public record NotificationFrozenPlan(
|
||||
+ policy.policyRevision()
|
||||
+ ", selectedLocale="
|
||||
+ selectedLocale.toLanguageTag()
|
||||
+ ", routeRevision="
|
||||
+ binding.routeRevision()
|
||||
+ ", bindingDigest="
|
||||
+ binding.bindingDigest()
|
||||
+ ", rendererRevision="
|
||||
+ binding.rendererRevision()
|
||||
+ ", targets=<redacted>"
|
||||
+ ", recipient=<redacted>, parameters=<redacted>, context=<redacted>, notBefore="
|
||||
+ notBefore
|
||||
+ ", expiresAt="
|
||||
+ expiresAt
|
||||
+ "]";
|
||||
}
|
||||
|
||||
/** Provider-neutral immutable execution graph persisted with the logical intent. */
|
||||
public record BindingSnapshot(
|
||||
int routeRevision,
|
||||
String bindingDigest,
|
||||
String templateChecksum,
|
||||
String rendererRevision,
|
||||
List<FrozenTarget> targets,
|
||||
boolean receiptRequired,
|
||||
Duration perAttemptDeadline) {
|
||||
|
||||
private static final Duration MAXIMUM_ATTEMPT_DEADLINE = Duration.ofMinutes(5);
|
||||
|
||||
public BindingSnapshot {
|
||||
if (routeRevision < 1 || routeRevision > 1_000_000) {
|
||||
throw new IllegalArgumentException("frozen route revision must be in 1..1000000");
|
||||
}
|
||||
bindingDigest = requireDigest("notification binding digest", bindingDigest);
|
||||
templateChecksum = requireDigest("notification template checksum", templateChecksum);
|
||||
rendererRevision =
|
||||
NotificationIntentId.requireSlug("notification renderer revision", rendererRevision);
|
||||
Objects.requireNonNull(targets, "frozen notification targets must be non-null");
|
||||
targets =
|
||||
targets.stream()
|
||||
.map(target -> Objects.requireNonNull(target, "frozen target must be non-null"))
|
||||
.sorted(Comparator.comparingInt(FrozenTarget::ordinal))
|
||||
.toList();
|
||||
if (targets.isEmpty() || targets.size() > 16) {
|
||||
throw new IllegalArgumentException(
|
||||
"frozen notification targets must contain 1..16 entries");
|
||||
}
|
||||
if (new HashSet<>(targets.stream().map(FrozenTarget::targetReference).toList()).size()
|
||||
!= targets.size()) {
|
||||
throw new IllegalArgumentException(
|
||||
"frozen notification targets contain duplicate references");
|
||||
}
|
||||
for (int index = 0; index < targets.size(); index++) {
|
||||
if (targets.get(index).ordinal() != index) {
|
||||
throw new IllegalArgumentException(
|
||||
"frozen notification target ordinals must be contiguous from zero");
|
||||
}
|
||||
}
|
||||
Objects.requireNonNull(
|
||||
perAttemptDeadline, "notification per-attempt deadline must be non-null");
|
||||
if (perAttemptDeadline.isZero()
|
||||
|| perAttemptDeadline.isNegative()
|
||||
|| perAttemptDeadline.compareTo(MAXIMUM_ATTEMPT_DEADLINE) > 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"notification per-attempt deadline must be positive and at most five minutes");
|
||||
}
|
||||
}
|
||||
|
||||
private static String requireDigest(String field, String digest) {
|
||||
if (digest == null || !digest.matches("[0-9a-f]{64}")) {
|
||||
throw new IllegalArgumentException(field + " must be a lowercase SHA-256 digest");
|
||||
}
|
||||
return digest;
|
||||
}
|
||||
}
|
||||
|
||||
/** Opaque provider-leg identity; credentials, endpoints and SDK types are deliberately absent. */
|
||||
public record FrozenTarget(
|
||||
int ordinal,
|
||||
String targetReference,
|
||||
String providerCapabilityReference,
|
||||
String providerBindingRevision,
|
||||
String credentialGeneration) {
|
||||
|
||||
public FrozenTarget {
|
||||
if (ordinal < 0 || ordinal > 15) {
|
||||
throw new IllegalArgumentException("frozen notification target ordinal must be in 0..15");
|
||||
}
|
||||
targetReference =
|
||||
NotificationIntentId.requireOpaque(
|
||||
"frozen notification target reference", targetReference);
|
||||
providerCapabilityReference =
|
||||
NotificationIntentId.requireOpaque(
|
||||
"frozen provider capability reference", providerCapabilityReference);
|
||||
providerBindingRevision =
|
||||
NotificationIntentId.requireOpaque(
|
||||
"frozen provider binding revision", providerBindingRevision);
|
||||
credentialGeneration =
|
||||
NotificationIntentId.requireSlug(
|
||||
"frozen provider credential generation", credentialGeneration);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "FrozenTarget[ordinal="
|
||||
+ ordinal
|
||||
+ ", targetReference=<redacted>, providerCapabilityReference="
|
||||
+ providerCapabilityReference
|
||||
+ ", providerBindingRevision="
|
||||
+ providerBindingRevision
|
||||
+ ", credentialGeneration="
|
||||
+ credentialGeneration
|
||||
+ "]";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -8,11 +8,12 @@ public record NotificationMaintenanceCommand(
|
||||
implements Command {
|
||||
|
||||
public NotificationMaintenanceCommand {
|
||||
long total = (long) maximumExpiredIntents + maximumPayloadRedactions + maximumExpiredReceipts;
|
||||
if (maximumExpiredIntents < 0
|
||||
|| maximumPayloadRedactions < 0
|
||||
|| maximumExpiredReceipts < 0
|
||||
|| maximumExpiredIntents + maximumPayloadRedactions + maximumExpiredReceipts < 1
|
||||
|| maximumExpiredIntents + maximumPayloadRedactions + maximumExpiredReceipts > 100) {
|
||||
|| total < 1
|
||||
|| total > 100) {
|
||||
throw new IllegalArgumentException(
|
||||
"notification maintenance total mutation bound must be in 1..100");
|
||||
}
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ public record NotificationMaintenanceResult(
|
||||
int expiredIntentCount, int redactedPayloadCount, int expiredReceiptCount) {
|
||||
|
||||
public NotificationMaintenanceResult {
|
||||
int total = expiredIntentCount + redactedPayloadCount + expiredReceiptCount;
|
||||
long total = (long) expiredIntentCount + redactedPayloadCount + expiredReceiptCount;
|
||||
if (expiredIntentCount < 0
|
||||
|| redactedPayloadCount < 0
|
||||
|| expiredReceiptCount < 0
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ public interface NotificationMaintenanceStorePort {
|
||||
record MutationResult(int expiredIntentCount, int redactedPayloadCount, int expiredReceiptCount) {
|
||||
|
||||
public MutationResult {
|
||||
int total = expiredIntentCount + redactedPayloadCount + expiredReceiptCount;
|
||||
long total = (long) expiredIntentCount + redactedPayloadCount + expiredReceiptCount;
|
||||
if (expiredIntentCount < 0
|
||||
|| redactedPayloadCount < 0
|
||||
|| expiredReceiptCount < 0
|
||||
|
||||
+23
@@ -88,6 +88,29 @@ class NotificationCapabilityCompatibilityValidatorTest {
|
||||
.hasMessageContaining("NOTIFICATION_CAPABILITY_INCOMPATIBLE");
|
||||
}
|
||||
|
||||
@Test
|
||||
void reconciliationPolicyRequiresProviderReconciliationCapability() {
|
||||
NotificationKindPolicy policy = policy();
|
||||
NotificationProviderCapabilityDescriptor withoutReconciliation =
|
||||
new NotificationProviderCapabilityDescriptor(
|
||||
"provider-capability-42",
|
||||
NotificationChannel.EMAIL,
|
||||
Set.of(NotificationMode.DURABLE_ASYNC),
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
16,
|
||||
1_000_000);
|
||||
|
||||
NotificationCapabilityCompatibilityValidator.Compatibility result =
|
||||
new NotificationCapabilityCompatibilityValidator()
|
||||
.validate(policy, withoutReconciliation, store(policy), Optional.of(ingress()), true);
|
||||
|
||||
assertThat(result.compatible()).isFalse();
|
||||
assertThat(result.reasonCodes())
|
||||
.containsExactly(new NotificationReasonCode("PROVIDER_RECONCILIATION_UNSUPPORTED"));
|
||||
}
|
||||
|
||||
private static NotificationKindPolicy policy() {
|
||||
return new NotificationKindPolicy(
|
||||
new NotificationKindId("security-alert"),
|
||||
|
||||
+32
-1
@@ -151,6 +151,34 @@ class NotificationDispatchUseCaseTest {
|
||||
assertThat(result.parkedCount()).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void staleParkGenerationRequeuesTheLegInsteadOfStrandingItAsParked() {
|
||||
List<String> trace = new ArrayList<>();
|
||||
RecordingStore store = new RecordingStore(trace, sampleClaim(), finalizationApplied());
|
||||
NotificationDispatchResult result =
|
||||
new NotificationDispatchUseCase(
|
||||
store,
|
||||
attempt ->
|
||||
new ProviderAttemptOutcome(
|
||||
SubmissionCertainty.DEFINITELY_NOT_APPLIED,
|
||||
RetryDisposition.PARK_BINDING,
|
||||
NotificationFaultScope.PROVIDER_BINDING,
|
||||
new NotificationReasonCode("PROVIDER_AUTH_REJECTED"),
|
||||
Optional.empty(),
|
||||
"correlation-42",
|
||||
Optional.empty()),
|
||||
request -> NotificationAdmissionReadinessPort.ParkResult.STALE_GENERATION,
|
||||
new TrackingTransactionPort(trace),
|
||||
Clock.fixed(NOW, ZoneOffset.UTC))
|
||||
.handle(new NotificationDispatchCommand(1));
|
||||
|
||||
assertThat(store.finalization.parkResult())
|
||||
.isEqualTo(NotificationAdmissionReadinessPort.ParkResult.STALE_GENERATION);
|
||||
assertThat(store.finalization.terminalState())
|
||||
.isEqualTo(NotificationDeliveryStorePort.TerminalState.RETRY_SCHEDULED);
|
||||
assertThat(result.parkedCount()).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void exactLateResultIsCountedWithoutBlindProviderRetry() {
|
||||
RecordingStore store =
|
||||
@@ -251,7 +279,10 @@ class NotificationDispatchUseCaseTest {
|
||||
Optional.empty(),
|
||||
NOW,
|
||||
NOW.plusSeconds(600));
|
||||
return NotificationFrozenPlan.from(draft, java.util.Locale.ENGLISH);
|
||||
return NotificationFrozenPlan.from(
|
||||
draft,
|
||||
java.util.Locale.ENGLISH,
|
||||
NotificationTestFixtures.binding(draft.policy().channel()));
|
||||
}
|
||||
|
||||
private static ProviderAttemptOutcome accepted() {
|
||||
|
||||
+11
@@ -33,6 +33,17 @@ class NotificationMaintenanceUseCaseTest {
|
||||
assertThat(result).isEqualTo(new NotificationMaintenanceResult(3, 2, 1));
|
||||
assertThatThrownBy(() -> new NotificationMaintenanceCommand(50, 50, 1))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(
|
||||
() -> new NotificationMaintenanceCommand(Integer.MAX_VALUE, Integer.MAX_VALUE, 3))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new NotificationMaintenanceStorePort.MutationResult(
|
||||
Integer.MAX_VALUE, Integer.MAX_VALUE, 3))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(
|
||||
() -> new NotificationMaintenanceResult(Integer.MAX_VALUE, Integer.MAX_VALUE, 3))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
private static final class TrackingTransactions implements TransactionPort {
|
||||
|
||||
+7
-2
@@ -26,7 +26,10 @@ class NotificationPlanningBoundaryTest {
|
||||
NotificationPlanPort planner =
|
||||
requested ->
|
||||
new NotificationPlanningResult.Planned(
|
||||
NotificationFrozenPlan.from(requested, Locale.ENGLISH));
|
||||
NotificationFrozenPlan.from(
|
||||
requested,
|
||||
Locale.ENGLISH,
|
||||
NotificationTestFixtures.binding(requested.policy().channel())));
|
||||
|
||||
NotificationPlanningResult result = planner.plan(draft);
|
||||
|
||||
@@ -46,7 +49,9 @@ class NotificationPlanningBoundaryTest {
|
||||
"recipient-ref-42",
|
||||
"source-operation-42",
|
||||
Instant.parse("2026-07-28T00:00:00Z"));
|
||||
NotificationFrozenPlan plan = NotificationFrozenPlan.from(draft, Locale.ENGLISH);
|
||||
NotificationFrozenPlan plan =
|
||||
NotificationFrozenPlan.from(
|
||||
draft, Locale.ENGLISH, NotificationTestFixtures.binding(draft.policy().channel()));
|
||||
AtomicReference<NotificationFrozenPlan> appended = new AtomicReference<>();
|
||||
AtomicReference<NotificationFrozenPlan> attempted = new AtomicReference<>();
|
||||
NotificationIntentAppendPort appendPort =
|
||||
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package dev.caskeleton.application.notification;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
|
||||
final class NotificationTestFixtures {
|
||||
|
||||
private NotificationTestFixtures() {}
|
||||
|
||||
static NotificationFrozenPlan.BindingSnapshot binding(NotificationChannel channel) {
|
||||
String capability =
|
||||
channel == NotificationChannel.EMAIL
|
||||
? "aws-ses-v2-durable-single-local-sns-v1"
|
||||
: "slack-web-api-durable-single-local-v1";
|
||||
return new NotificationFrozenPlan.BindingSnapshot(
|
||||
3,
|
||||
"a".repeat(64),
|
||||
"b".repeat(64),
|
||||
"renderer-r3",
|
||||
List.of(
|
||||
new NotificationFrozenPlan.FrozenTarget(
|
||||
0, "target-r3", capability, "provider-binding-r3", "credential-r3")),
|
||||
channel == NotificationChannel.EMAIL,
|
||||
Duration.ofSeconds(5));
|
||||
}
|
||||
}
|
||||
+80
-1
@@ -193,14 +193,93 @@ class NotificationValueContractTest {
|
||||
notBefore,
|
||||
notBefore.plusSeconds(60));
|
||||
|
||||
NotificationFrozenPlan plan = NotificationFrozenPlan.from(draft, Locale.forLanguageTag("en"));
|
||||
NotificationFrozenPlan plan =
|
||||
NotificationFrozenPlan.from(
|
||||
draft,
|
||||
Locale.forLanguageTag("en"),
|
||||
NotificationTestFixtures.binding(draft.policy().channel()));
|
||||
|
||||
assertThat(plan.selectedLocale()).isEqualTo(Locale.ENGLISH);
|
||||
assertThat(plan.mode()).isEqualTo(NotificationMode.DURABLE_ASYNC);
|
||||
assertThat(plan.binding().targets()).isUnmodifiable();
|
||||
assertThat(plan.routeId()).isEqualTo(new NotificationRouteId("email-primary"));
|
||||
assertThat(plan.toString()).doesNotContain("recipient-ref-42").doesNotContain("Ada");
|
||||
}
|
||||
|
||||
@Test
|
||||
void publicFrozenPlanConstructorPreservesDraftContextAndRetryHorizonInvariants() {
|
||||
NotificationKindPolicy policy = durablePolicy(Duration.ofHours(1));
|
||||
Instant notBefore = Instant.parse("2026-07-28T00:00:00Z");
|
||||
NotificationFrozenPlan valid =
|
||||
new NotificationFrozenPlan(
|
||||
new NotificationIntentId("intent-42"),
|
||||
policy,
|
||||
Locale.ENGLISH,
|
||||
NotificationTestFixtures.binding(policy.channel()),
|
||||
new EmailRecipientReference("recipient-ref-42"),
|
||||
new NotificationTemplateParameters(
|
||||
Map.of("displayName", new NotificationTemplateValue.SafeText("Ada"))),
|
||||
"password-reset",
|
||||
"source-operation-42",
|
||||
Optional.empty(),
|
||||
"correlation-42",
|
||||
Optional.empty(),
|
||||
notBefore,
|
||||
notBefore.plusSeconds(60));
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new NotificationFrozenPlan(
|
||||
valid.intentId(),
|
||||
policy,
|
||||
valid.selectedLocale(),
|
||||
valid.binding(),
|
||||
valid.recipient(),
|
||||
valid.parameters(),
|
||||
valid.idempotencyScope(),
|
||||
valid.sourceOperationId(),
|
||||
Optional.of(" "),
|
||||
valid.correlationReference(),
|
||||
valid.causationReference(),
|
||||
notBefore,
|
||||
notBefore.plusSeconds(60)))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new NotificationFrozenPlan(
|
||||
valid.intentId(),
|
||||
policy,
|
||||
valid.selectedLocale(),
|
||||
valid.binding(),
|
||||
valid.recipient(),
|
||||
valid.parameters(),
|
||||
valid.idempotencyScope(),
|
||||
valid.sourceOperationId(),
|
||||
valid.tenantReference(),
|
||||
valid.correlationReference(),
|
||||
Optional.of("\n"),
|
||||
notBefore,
|
||||
notBefore.plusSeconds(60)))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new NotificationFrozenPlan(
|
||||
valid.intentId(),
|
||||
policy,
|
||||
valid.selectedLocale(),
|
||||
valid.binding(),
|
||||
valid.recipient(),
|
||||
valid.parameters(),
|
||||
valid.idempotencyScope(),
|
||||
valid.sourceOperationId(),
|
||||
valid.tenantReference(),
|
||||
valid.correlationReference(),
|
||||
valid.causationReference(),
|
||||
notBefore,
|
||||
notBefore.plus(Duration.ofHours(2))))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
private static NotificationKindPolicy durablePolicy(Duration retryHorizon) {
|
||||
return new NotificationKindPolicy(
|
||||
new NotificationKindId("password-reset"),
|
||||
|
||||
+9
@@ -81,7 +81,16 @@ class ReconcileNotificationDeliveriesUseCaseTest {
|
||||
new NotificationDeliveryId("delivery-42"),
|
||||
"reconcile-token-42",
|
||||
3,
|
||||
new NotificationRouteId("security-slack"),
|
||||
3,
|
||||
"a".repeat(64),
|
||||
0,
|
||||
"slack-primary",
|
||||
"slack-web-api-durable-single-local-v1",
|
||||
"slack-binding-r1",
|
||||
"credential-r1",
|
||||
"provider-message-42",
|
||||
NotificationDeliveryStorePort.ReconciliationLookupKind.MESSAGE_REFERENCE,
|
||||
NOW.plusSeconds(30)));
|
||||
|
||||
private RecordingStore(List<String> trace) {
|
||||
|
||||
Reference in New Issue
Block a user