init: 클린 아키텍처 백엔드
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
# adapter:outbound:notification — notification adapter
|
||||
|
||||
## Registered identity
|
||||
|
||||
- Module ID: `adapter-outbound-notification`
|
||||
- Gradle path: `:adapter:outbound:notification`
|
||||
- Focused test: `./gradlew :adapter:outbound:notification:test --console=plain`
|
||||
- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0.
|
||||
- Registry SSOT: `.harness/project/modules.yaml`.
|
||||
|
||||
Package root: `dev.caskeleton.adapter.outbound.notification`.
|
||||
|
||||
## Responsibility
|
||||
|
||||
- Implement notification provider routing and provider-specific Slack/email clients behind ports.
|
||||
- Own provider settings, technical fallback, and provider adaptation.
|
||||
- Reuse `adapter:outbound:support` for shared outbound concerns.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Allowed dependency edges come only from `.harness/project/modules.yaml`.
|
||||
- No inbound DTO/controller, persistence, bootstrap, or sample dependency.
|
||||
- Provider selection may route configured channels but must not encode business eligibility rules.
|
||||
|
||||
## Tests
|
||||
|
||||
Use provider/client fakes and contract tests; no real webhook or email network calls. Settings changes
|
||||
include binding/validation tests.
|
||||
@@ -0,0 +1,34 @@
|
||||
# adapter:outbound:notification — 설계 결정 참조
|
||||
|
||||
알림(email/Slack 등) 아웃바운드 어댑터 모듈. 패키지 루트:
|
||||
`dev.caskeleton.adapter.outbound.notification`. `:adapter:outbound:support` 에 의존해 공유
|
||||
correlation / fail-open 의존성 로깅을 재사용한다.
|
||||
|
||||
허용/금지 의존 정책은 `src/build.gradle` 의
|
||||
`allowedProjectDependencies['adapter:outbound:notification']` 항목이 SSOT 다(이 모듈은 아직
|
||||
별도 CLAUDE.md 를 두지 않았다). 이 문서는 코드 주석에서 덜어낸 **설계 결정의 근거**를 모아둔
|
||||
참조용 기록이다.
|
||||
|
||||
## 모듈 개요
|
||||
|
||||
application-core 포트 뒤에 두는 **선택형** 알림 어댑터다. `@ConditionalOnProperty` 로 게이팅되고
|
||||
기본 비활성이다. 이 모듈이 기본 제공하는 프로바이더는 `email/google`(`GoogleEmailProvider` /
|
||||
`GoogleEmailClient`)과 `slack/webhook`(`SlackWebhookProvider` / `SlackClient`)이며, 실제 연동
|
||||
client 는 포킹 프로젝트가 채우는 seam 이다.
|
||||
|
||||
## (channel, providerId) 복합 키 + fan-out
|
||||
|
||||
`RoutingNotifier` 는 `(channel, providerId)` 복합 키로 프로바이더를 등록한다 — 채널 내 중복
|
||||
`providerId` 는 생성 시점에 실패한다. 라우트당 providerId 목록을 주면 fan-out(모든 프로바이더
|
||||
호출)이 된다. 각 프로바이더는 이미 `FailOpenNotificationProvider` 로 감싸져 있어 한 곳의 실패가
|
||||
다른 곳을 막지 않고, 그래서 fan-out 루프에 try/catch 가 필요 없다. `FailOpenNotificationProvider
|
||||
.send` 가 `throws` 를 선언하지 않는 건 이 루프를 try/catch 없이 예외-free 로 증명하기 위함이다.
|
||||
|
||||
## 중앙 fail-open 합성 + 라우팅 바인딩
|
||||
|
||||
`NotificationConfig` 가 모든 프로바이더를 `FailOpenNotificationProvider` 로 중앙에서 감싼다(→
|
||||
`:adapter:outbound:support` 의 `FailOpenDependencyLogger` 로 WARN 로깅). 라우팅은
|
||||
`app.notification.routes.<channel>.<route>=<providerId>[,<providerId>]`. 프로바이더는
|
||||
`channel()`+`providerId()` 로 키잉된 `NotificationProvider` 빈으로 기여한다(예:
|
||||
`GoogleEmailProvider`, `SlackWebhookProvider`). `GoogleEmailClient`/`SlackClient` 는 포크가
|
||||
구현하는 seam 이며 실패는 데코레이터가 fail-open 처리한다.
|
||||
@@ -0,0 +1,15 @@
|
||||
plugins { id 'groovy' }
|
||||
dependencies {
|
||||
implementation project(':domain-core')
|
||||
implementation project(':application-core')
|
||||
implementation project(':shared-contract')
|
||||
implementation project(':adapter:outbound:support')
|
||||
|
||||
implementation 'org.springframework.boot:spring-boot-autoconfigure'
|
||||
implementation 'org.springframework:spring-web' // Slack webhook client (RestClient)
|
||||
implementation 'org.slf4j:slf4j-api'
|
||||
|
||||
testImplementation 'org.spockframework:spock-core:2.4-groovy-5.0'
|
||||
}
|
||||
tasks.withType(GroovyCompile).configureEach { groovyOptions.encoding = 'UTF-8'; options.encoding = 'UTF-8' }
|
||||
tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' }
|
||||
@@ -0,0 +1,156 @@
|
||||
# This is a Gradle generated file for dependency locking.
|
||||
# Manual edits can break the build and are not advised.
|
||||
# This file is expected to be part of source control.
|
||||
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=testCompileClasspath
|
||||
ch.qos.logback:logback-classic:1.5.21=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
ch.qos.logback:logback-core:1.5.21=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath
|
||||
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor
|
||||
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor
|
||||
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
|
||||
com.github.spotbugs:spotbugs-annotations:4.8.6=testCompileClasspath
|
||||
com.github.spotbugs:spotbugs:4.10.2=spotbugs
|
||||
com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs
|
||||
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor
|
||||
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor
|
||||
com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor
|
||||
com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs,testCompileClasspath
|
||||
com.google.code.gson:gson:2.13.2=spotbugs
|
||||
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_annotations:2.38.0=testCompileClasspath
|
||||
com.google.errorprone:error_prone_annotations:2.41.0=spotbugs
|
||||
com.google.errorprone:error_prone_annotations:2.47.0=checkstyle
|
||||
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||
com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor
|
||||
com.google.guava:guava:33.6.0-jre=checkstyle
|
||||
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor
|
||||
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
|
||||
com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath
|
||||
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
|
||||
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath
|
||||
commons-beanutils:commons-beanutils:1.11.0=checkstyle
|
||||
commons-collections:commons-collections:3.2.2=checkstyle
|
||||
commons-io:commons-io:2.21.0=spotbugs
|
||||
commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
info.picocli:picocli:4.7.7=checkstyle
|
||||
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor
|
||||
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor
|
||||
io.leangen.geantyref:geantyref:1.3.16=testRuntimeClasspath
|
||||
io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.annotation:jakarta.annotation-api:3.0.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath
|
||||
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor
|
||||
jaxen:jaxen:2.0.0=spotbugs
|
||||
net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath
|
||||
net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath
|
||||
net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
|
||||
net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
|
||||
net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs
|
||||
org.antlr:antlr4-runtime:4.13.2=checkstyle
|
||||
org.apache.bcel:bcel:6.12.0=spotbugs
|
||||
org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs
|
||||
org.apache.commons:commons-text:1.15.0=spotbugs
|
||||
org.apache.commons:commons-text:1.3=checkstyle
|
||||
org.apache.groovy:groovy-bom:5.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.groovy:groovy:5.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
|
||||
org.apache.httpcomponents:httpcore:4.4.16=checkstyle
|
||||
org.apache.logging.log4j:log4j-api:2.25.2=runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.logging.log4j:log4j-core:2.25.2=spotbugs
|
||||
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.maven.doxia:doxia-core:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle
|
||||
org.apache.tomcat.embed:tomcat-embed-core:11.0.14=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.tomcat.embed:tomcat-embed-el:11.0.14=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.xbean:xbean-reflect:3.7=checkstyle
|
||||
org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath
|
||||
org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath
|
||||
org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle
|
||||
org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle
|
||||
org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle
|
||||
org.codehaus.plexus:plexus-utils:3.3.0=checkstyle
|
||||
org.dom4j:dom4j:2.2.0=spotbugs
|
||||
org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.javassist:javassist:3.28.0-GA=checkstyle
|
||||
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-engine:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath
|
||||
org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit:junit-bom:6.1.0=spotbugs
|
||||
org.mockito:mockito-core:5.20.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.objenesis:objenesis:3.3=testRuntimeClasspath
|
||||
org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.osgi:org.osgi.annotation.bundle:2.0.0=testCompileClasspath
|
||||
org.osgi:org.osgi.annotation.versioning:1.1.2=testCompileClasspath
|
||||
org.osgi:org.osgi.resource:1.0.0=testCompileClasspath
|
||||
org.osgi:org.osgi.service.serviceloader:1.0.0=testCompileClasspath
|
||||
org.ow2.asm:asm-analysis:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-commons:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-tree:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-util:9.10.1=spotbugs
|
||||
org.ow2.asm:asm:9.10.1=spotbugs
|
||||
org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor
|
||||
org.reflections:reflections:0.10.2=checkstyle
|
||||
org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:jul-to-slf4j:2.0.17=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j
|
||||
org.spockframework:spock-bom:2.4-groovy-5.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.spockframework:spock-core:2.4-groovy-5.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-logging:4.0.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter:4.0.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-web-server:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-web:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
|
||||
org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath
|
||||
org.yaml:snakeyaml:2.5=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson:jackson-bom:3.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
empty=
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package dev.caskeleton.adapter.outbound.notification;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.notification.core.FailOpenNotificationProvider;
|
||||
import dev.caskeleton.adapter.outbound.notification.core.NotificationProvider;
|
||||
import dev.caskeleton.adapter.outbound.notification.core.RoutingNotifier;
|
||||
import dev.caskeleton.adapter.outbound.support.FailOpenDependencyLogger;
|
||||
import dev.caskeleton.application.notification.NotificationPort;
|
||||
import java.util.List;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* Assembles the {@link RoutingNotifier} from every contributed {@link NotificationProvider} bean,
|
||||
* mirroring {@code CacheRouterConfig}.
|
||||
*
|
||||
* <p>Provider discovery is type-explicit: a provider opts in by registering a {@link
|
||||
* NotificationProvider} bean (its {@link NotificationProvider#providerId()} + {@link
|
||||
* NotificationProvider#channel()} are the routing keys). Adding a provider is therefore new files
|
||||
* only — this config and {@link RoutingNotifier} never change. The fail-open policy is applied
|
||||
* here, centrally, by wrapping every provider in {@link FailOpenNotificationProvider} — a provider
|
||||
* config cannot forget it.
|
||||
*/
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(NotificationRoutesSettings.class)
|
||||
public class NotificationConfig {
|
||||
|
||||
@Bean
|
||||
public NotificationPort notificationPort(
|
||||
ObjectProvider<List<NotificationProvider>> providers,
|
||||
NotificationRoutesSettings settings,
|
||||
FailOpenDependencyLogger failOpenDependencyLogger) {
|
||||
List<FailOpenNotificationProvider> failOpenProviders =
|
||||
providers.getIfAvailable(List::of).stream()
|
||||
.map(p -> new FailOpenNotificationProvider(p, failOpenDependencyLogger))
|
||||
.toList();
|
||||
return new RoutingNotifier(failOpenProviders, settings.routes());
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package dev.caskeleton.adapter.outbound.notification;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.notification.core.RoutingNotifier;
|
||||
import dev.caskeleton.application.notification.Channel;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/**
|
||||
* Routes binding for the multi-provider notification template: {@code
|
||||
* app.notification.routes.<channel>.<route>=<providerId>[,<providerId>]}.
|
||||
*
|
||||
* <p>Mirrors {@code CacheBindingSettings}: relaxed binding maps the channel segment (e.g. {@code
|
||||
* email} → {@link Channel#EMAIL}) automatically via Spring's {@code ApplicationConversionService}.
|
||||
* Route values are coerced to {@code List<String>} by the binder (comma-separated or YAML list).
|
||||
* Default is an empty map so the notification template stays a non-required optional module:
|
||||
* startup never fails when no routes are configured. Consistency (every referenced providerId has
|
||||
* an enabled provider) is validated fail-fast by {@link RoutingNotifier} at construction time.
|
||||
*
|
||||
* @param routes channel → (route → providerId list), default empty
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "app.notification")
|
||||
public record NotificationRoutesSettings(Map<Channel, Map<String, List<String>>> routes) {
|
||||
|
||||
public NotificationRoutesSettings {
|
||||
routes = (routes == null) ? Map.of() : Map.copyOf(routes);
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.core;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.support.FailOpenDependencyLogger;
|
||||
import dev.caskeleton.application.notification.Channel;
|
||||
import dev.caskeleton.application.notification.Notification;
|
||||
|
||||
/**
|
||||
* Fail-open decorator: a provider failure is logged (no payload/PII) and swallowed so a
|
||||
* notification — a side-effect — never fails the core use case. Applied centrally by
|
||||
* NotificationConfig.
|
||||
*/
|
||||
public final class FailOpenNotificationProvider implements NotificationProvider {
|
||||
|
||||
private static final String DEPENDENCY_TYPE = "notification";
|
||||
|
||||
private final NotificationProvider delegate;
|
||||
private final FailOpenDependencyLogger dependencyLogger;
|
||||
|
||||
public FailOpenNotificationProvider(
|
||||
NotificationProvider delegate, FailOpenDependencyLogger dependencyLogger) {
|
||||
this.delegate = delegate;
|
||||
this.dependencyLogger = dependencyLogger;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Channel channel() {
|
||||
return delegate.channel();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String providerId() {
|
||||
return delegate.providerId();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void send(Notification notification) {
|
||||
try {
|
||||
delegate.send(notification);
|
||||
dependencyLogger.logSuccess(delegate.providerId(), DEPENDENCY_TYPE, "send");
|
||||
} catch (Exception ex) {
|
||||
// fail-open: observe (no payload/PII), do not fail the core use case.
|
||||
dependencyLogger.logFailure(delegate.providerId(), DEPENDENCY_TYPE, "send", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.core;
|
||||
|
||||
import dev.caskeleton.application.notification.Channel;
|
||||
import dev.caskeleton.application.notification.Notification;
|
||||
|
||||
/**
|
||||
* SPI a forking project contributes to bind the notification template to a real provider for a
|
||||
* given {@link Channel}. Each provider registers a bean of this type. {@link #providerId()} is the
|
||||
* identifier referenced by {@code app.notification.routes.<channel>.<route>=<id>[,<id>]} values and
|
||||
* must be unique within a channel.
|
||||
*/
|
||||
public interface NotificationProvider {
|
||||
|
||||
/** The channel this provider delivers to (e.g. {@link Channel#EMAIL}). */
|
||||
Channel channel();
|
||||
|
||||
/**
|
||||
* Stable provider id referenced by {@code app.notification.routes.*}; unique within a channel.
|
||||
*/
|
||||
String providerId();
|
||||
|
||||
/**
|
||||
* Sends the notification via the provider. May throw on any transport/provider failure; the
|
||||
* {@link FailOpenNotificationProvider} decorator applies the fail-open policy centrally — this
|
||||
* method must not swallow its own exceptions.
|
||||
*
|
||||
* @param notification the notification to send (contains PII — never log this value)
|
||||
* @throws Exception on a send failure (caught and handled fail-open by the decorator)
|
||||
*/
|
||||
void send(Notification notification) throws Exception;
|
||||
}
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.core;
|
||||
|
||||
import dev.caskeleton.application.notification.Channel;
|
||||
import dev.caskeleton.application.notification.Notification;
|
||||
import dev.caskeleton.application.notification.NotificationPort;
|
||||
import dev.caskeleton.shared.error.AdapterDisabledException;
|
||||
import java.util.Collection;
|
||||
import java.util.EnumMap;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Routes notification calls to contributed {@link NotificationProvider}s ({@code
|
||||
* app.notification.routes.<channel>.<route>=<providerId>[,<providerId>]}). A duplicate {@code
|
||||
* providerId} within a channel, or a route to a providerId with no enabled provider, fails
|
||||
* construction; {@code notify} on an unbound channel/route throws {@link AdapterDisabledException}
|
||||
* (Layer 3, never a silent no-op). A route's providerId list is fanned out to all providers, each
|
||||
* already wrapped in {@link FailOpenNotificationProvider}, so one failure does not block the others
|
||||
* — hence no try/catch in the fan-out loop. It does not expose the resolved provider, so no adapter
|
||||
* type escapes via a public return (B7).
|
||||
*/
|
||||
public final class RoutingNotifier implements NotificationPort {
|
||||
|
||||
private static final String ADAPTER_NAME = "notification";
|
||||
|
||||
private final Map<Channel, Map<String, FailOpenNotificationProvider>> registry;
|
||||
|
||||
private final Map<Channel, Map<String, List<String>>> routes;
|
||||
|
||||
public RoutingNotifier(
|
||||
Collection<? extends FailOpenNotificationProvider> providers,
|
||||
Map<Channel, Map<String, List<String>>> routes) {
|
||||
this.registry = buildRegistry(providers);
|
||||
validateRoutes(routes, this.registry);
|
||||
this.routes = immutableRoutesCopy(routes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Indexes contributed providers by {@code (channel, providerId)}. A duplicate providerId within a
|
||||
* channel fails at construction (boot).
|
||||
*/
|
||||
private static Map<Channel, Map<String, FailOpenNotificationProvider>> buildRegistry(
|
||||
Collection<? extends FailOpenNotificationProvider> providers) {
|
||||
Map<Channel, Map<String, FailOpenNotificationProvider>> registry = new EnumMap<>(Channel.class);
|
||||
for (FailOpenNotificationProvider provider : providers) {
|
||||
Map<String, FailOpenNotificationProvider> byId =
|
||||
registry.computeIfAbsent(provider.channel(), ch -> new HashMap<>());
|
||||
FailOpenNotificationProvider previous = byId.putIfAbsent(provider.providerId(), provider);
|
||||
if (previous != null) {
|
||||
throw new IllegalStateException(
|
||||
"duplicate notification providerId '"
|
||||
+ provider.providerId()
|
||||
+ "' for channel "
|
||||
+ provider.channel()
|
||||
+ " — every contributed NotificationProvider bean must have a"
|
||||
+ " unique providerId within its channel");
|
||||
}
|
||||
}
|
||||
Map<Channel, Map<String, FailOpenNotificationProvider>> immutable =
|
||||
new EnumMap<>(Channel.class);
|
||||
registry.forEach((channel, byId) -> immutable.put(channel, Map.copyOf(byId)));
|
||||
return Map.copyOf(immutable);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fails at construction (boot) if any route references a providerId with no enabled provider on
|
||||
* that channel. The per-channel registry lookup is hoisted out of the inner loops — it depends
|
||||
* only on the channel.
|
||||
*/
|
||||
private static void validateRoutes(
|
||||
Map<Channel, Map<String, List<String>>> routes,
|
||||
Map<Channel, Map<String, FailOpenNotificationProvider>> registry) {
|
||||
for (Map.Entry<Channel, Map<String, List<String>>> channelEntry : routes.entrySet()) {
|
||||
Channel channel = channelEntry.getKey();
|
||||
Map<String, FailOpenNotificationProvider> channelRegistry =
|
||||
registry.getOrDefault(channel, Map.of());
|
||||
for (Map.Entry<String, List<String>> routeEntry : channelEntry.getValue().entrySet()) {
|
||||
String route = routeEntry.getKey();
|
||||
for (String providerId : routeEntry.getValue()) {
|
||||
if (!channelRegistry.containsKey(providerId)) {
|
||||
throw new IllegalStateException(
|
||||
"app.notification.routes."
|
||||
+ channel.name().toLowerCase()
|
||||
+ "."
|
||||
+ route
|
||||
+ " references providerId '"
|
||||
+ providerId
|
||||
+ "' but no enabled provider contributes that id for channel "
|
||||
+ channel
|
||||
+ " — enable the provider or fix the route binding");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Deep, immutable copy of the routes map (channel → route → providerId list). */
|
||||
private static Map<Channel, Map<String, List<String>>> immutableRoutesCopy(
|
||||
Map<Channel, Map<String, List<String>>> routes) {
|
||||
Map<Channel, Map<String, List<String>>> immutable = new EnumMap<>(Channel.class);
|
||||
routes.forEach(
|
||||
(channel, routeMap) -> {
|
||||
Map<String, List<String>> copy = new HashMap<>();
|
||||
routeMap.forEach((route, ids) -> copy.put(route, List.copyOf(ids)));
|
||||
immutable.put(channel, Map.copyOf(copy));
|
||||
});
|
||||
return Map.copyOf(immutable);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void notify(Channel channel, String route, Notification notification) {
|
||||
List<String> providerIds = resolveRoute(channel, route);
|
||||
Map<String, FailOpenNotificationProvider> channelRegistry =
|
||||
registry.getOrDefault(channel, Map.of());
|
||||
// FailOpenNotificationProvider.send declares no throws — no try/catch needed.
|
||||
// Individual provider failures are observed (logged) inside the decorator
|
||||
// and never propagated, so one failure does not block remaining fan-out sends.
|
||||
for (String providerId : providerIds) {
|
||||
channelRegistry.get(providerId).send(notification);
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> resolveRoute(Channel channel, String route) {
|
||||
Map<String, List<String>> channelRoutes = routes.get(channel);
|
||||
if (channelRoutes == null || !channelRoutes.containsKey(route)) {
|
||||
throw new AdapterDisabledException(
|
||||
ADAPTER_NAME,
|
||||
"no notification route bound for channel="
|
||||
+ channel
|
||||
+ " route='"
|
||||
+ route
|
||||
+ "' — set app.notification.routes."
|
||||
+ channel.name().toLowerCase()
|
||||
+ "."
|
||||
+ route
|
||||
+ "=<providerId>[,<providerId>] and enable that provider"
|
||||
+ " (integration-adapter-templates Layer 3)");
|
||||
}
|
||||
return channelRoutes.get(route);
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.email.google;
|
||||
|
||||
import dev.caskeleton.application.notification.Notification;
|
||||
|
||||
/**
|
||||
* Integration seam the forking project implements to bind the Google Email template to a real
|
||||
* client (Gmail API / SMTP). The skeleton carries no mail SDK dependency — it is supplied by the
|
||||
* project that enables Google Email.
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface GoogleEmailClient {
|
||||
|
||||
/**
|
||||
* Sends an email notification. May throw on any transport/provider failure.
|
||||
*
|
||||
* @throws Exception on a send failure (caught and handled fail-open by the adapter)
|
||||
*/
|
||||
void send(Notification notification) throws Exception;
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.email.google;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.notification.core.NotificationProvider;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* Layer 1 gating for the Google Email provider template: registers the Google Email {@link
|
||||
* NotificationProvider} only when {@code app.notification.google-email.enabled=true}. Needs a
|
||||
* project-supplied {@link GoogleEmailClient} bean (the integration seam). Adding another email
|
||||
* provider is a new config like this one — this file never changes.
|
||||
*/
|
||||
@Configuration
|
||||
public class GoogleEmailNotificationAdapterConfig {
|
||||
|
||||
@Bean
|
||||
@ConditionalOnProperty(
|
||||
name = "app.notification.google-email.enabled",
|
||||
havingValue = "true",
|
||||
matchIfMissing = false)
|
||||
public NotificationProvider googleEmailProvider(GoogleEmailClient googleEmailClient) {
|
||||
return new GoogleEmailProvider(googleEmailClient);
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.email.google;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.notification.core.NotificationProvider;
|
||||
import dev.caskeleton.application.notification.Channel;
|
||||
import dev.caskeleton.application.notification.Notification;
|
||||
|
||||
/**
|
||||
* Google Email contribution of the {@link NotificationProvider} SPI (channel {@link Channel#EMAIL},
|
||||
* providerId {@code "google-email"}). Delegates the raw send to the project-supplied {@link
|
||||
* GoogleEmailClient} seam; the fail-open policy and PII-safe logging are applied centrally by
|
||||
* {@link dev.caskeleton.adapter.outbound.notification.core.FailOpenNotificationProvider}, not here
|
||||
* — so this class carries no policy and no mail SDK.
|
||||
*
|
||||
* <p>Adding another email provider (e.g. AWS SES) is a new sibling file with a different {@code
|
||||
* providerId} — this file never changes.
|
||||
*/
|
||||
public class GoogleEmailProvider implements NotificationProvider {
|
||||
|
||||
private static final String PROVIDER_ID = "google-email";
|
||||
|
||||
private final GoogleEmailClient client;
|
||||
|
||||
public GoogleEmailProvider(GoogleEmailClient client) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Channel channel() {
|
||||
return Channel.EMAIL;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String providerId() {
|
||||
return PROVIDER_ID;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void send(Notification notification) throws Exception {
|
||||
client.send(notification);
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.slack.webhook;
|
||||
|
||||
import dev.caskeleton.application.notification.Notification;
|
||||
|
||||
/**
|
||||
* Integration seam the forking project implements to bind the Slack template to a real Slack client
|
||||
* (incoming-webhook / Web API). The skeleton carries no Slack SDK dependency — it is supplied by
|
||||
* the project that enables Slack.
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface SlackClient {
|
||||
|
||||
/**
|
||||
* Sends a notification to Slack. May throw on any transport/provider failure.
|
||||
*
|
||||
* @throws Exception on a send failure (caught and handled fail-open by the adapter)
|
||||
*/
|
||||
void send(Notification notification) throws Exception;
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.slack.webhook;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.notification.core.NotificationProvider;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* Layer 1 gating for the Slack webhook provider template: registers the Slack webhook {@link
|
||||
* NotificationProvider} only when {@code app.notification.slack-webhook.enabled=true}. Needs a
|
||||
* project-supplied {@link SlackClient} bean (the integration seam). Adding another Slack provider
|
||||
* is a new config like this one — this file never changes.
|
||||
*/
|
||||
@Configuration
|
||||
public class SlackNotificationAdapterConfig {
|
||||
|
||||
@Bean
|
||||
@ConditionalOnProperty(
|
||||
name = "app.notification.slack-webhook.enabled",
|
||||
havingValue = "true",
|
||||
matchIfMissing = false)
|
||||
public NotificationProvider slackWebhookProvider(SlackClient slackClient) {
|
||||
return new SlackWebhookProvider(slackClient);
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.slack.webhook;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.notification.core.NotificationProvider;
|
||||
import dev.caskeleton.application.notification.Channel;
|
||||
import dev.caskeleton.application.notification.Notification;
|
||||
|
||||
/**
|
||||
* Slack incoming-webhook contribution of the {@link NotificationProvider} SPI (channel {@link
|
||||
* Channel#SLACK}, providerId {@code "slack-webhook"}). Delegates the raw send to the
|
||||
* project-supplied {@link SlackClient} seam; the fail-open policy and PII-safe logging are applied
|
||||
* centrally by {@link
|
||||
* dev.caskeleton.adapter.outbound.notification.core.FailOpenNotificationProvider}, not here — so
|
||||
* this class carries no policy and no Slack SDK.
|
||||
*
|
||||
* <p>A second Slack provider (e.g. a Web-API client) is added as a sibling {@link
|
||||
* NotificationProvider} with a different {@code providerId}.
|
||||
*/
|
||||
public class SlackWebhookProvider implements NotificationProvider {
|
||||
|
||||
private static final String PROVIDER_ID = "slack-webhook";
|
||||
|
||||
private final SlackClient client;
|
||||
|
||||
public SlackWebhookProvider(SlackClient client) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Channel channel() {
|
||||
return Channel.SLACK;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String providerId() {
|
||||
return PROVIDER_ID;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void send(Notification notification) throws Exception {
|
||||
client.send(notification);
|
||||
}
|
||||
}
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.core;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import ch.qos.logback.classic.Level;
|
||||
import ch.qos.logback.classic.spi.ILoggingEvent;
|
||||
import ch.qos.logback.core.read.ListAppender;
|
||||
import dev.caskeleton.adapter.outbound.support.FailOpenDependencyLogger;
|
||||
import dev.caskeleton.adapter.outbound.support.OutboundCorrelation;
|
||||
import dev.caskeleton.application.notification.Channel;
|
||||
import dev.caskeleton.application.notification.Notification;
|
||||
import dev.caskeleton.shared.error.AdapterDisabledException;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.slf4j.MDC;
|
||||
|
||||
/**
|
||||
* Adapter-level behavior contract for the router-fail-fast notification design: {@link
|
||||
* RoutingNotifier} + {@link FailOpenNotificationProvider}.
|
||||
*
|
||||
* <p>Contracts verified here (plan §4 / router-fail-fast shape, aligned with the cache template):
|
||||
*
|
||||
* <ul>
|
||||
* <li>Fail-open: a provider send failure is observed (no PII) and never rethrown.
|
||||
* <li>Fail-fast: an unbound route throws {@link AdapterDisabledException}.
|
||||
* <li>PII contract: {@link Notification} (recipient/body) NEVER appears in a log line.
|
||||
* </ul>
|
||||
*
|
||||
* <p>The old per-channel {@code Disabled*Notifier} sentinel tests are removed — the
|
||||
* router-fail-fast design has no per-channel sentinel; Layer 3 is now enforced by {@link
|
||||
* RoutingNotifier#notify} for unbound routes (mirror of cache D4).
|
||||
*/
|
||||
class NotificationAdapterTest {
|
||||
|
||||
// PII markers placed in the notification to assert they never reach a log line.
|
||||
private static final Notification PII_NOTIFICATION =
|
||||
new Notification("secret@gmail.com", "subject", "secret-body-content");
|
||||
|
||||
private ch.qos.logback.classic.Logger logbackLogger;
|
||||
private ListAppender<ILoggingEvent> appender;
|
||||
private FailOpenDependencyLogger dependencyLogger;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
logbackLogger = (ch.qos.logback.classic.Logger) LoggerFactory.getLogger("test.notification");
|
||||
appender = new ListAppender<>();
|
||||
appender.start();
|
||||
logbackLogger.addAppender(appender);
|
||||
logbackLogger.setLevel(Level.DEBUG);
|
||||
dependencyLogger = new FailOpenDependencyLogger(logbackLogger);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
logbackLogger.detachAppender(appender);
|
||||
MDC.clear();
|
||||
}
|
||||
|
||||
private static NotificationProvider failingProvider(Channel channel, String id) {
|
||||
return new NotificationProvider() {
|
||||
@Override
|
||||
public Channel channel() {
|
||||
return channel;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String providerId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void send(Notification n) throws Exception {
|
||||
throw new RuntimeException("provider-failure");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Fail-open (FailOpenNotificationProvider)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void emailSendFailureIsFailOpenAndNeverRethrows() {
|
||||
FailOpenNotificationProvider failOpen =
|
||||
new FailOpenNotificationProvider(
|
||||
failingProvider(Channel.EMAIL, "google-email"), dependencyLogger);
|
||||
|
||||
assertThatCode(() -> failOpen.send(PII_NOTIFICATION)).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void emailSendFailureLogContainsProviderIdAndNoPii() {
|
||||
MDC.put(OutboundCorrelation.MDC_KEY, "corr-email-1");
|
||||
FailOpenNotificationProvider failOpen =
|
||||
new FailOpenNotificationProvider(
|
||||
failingProvider(Channel.EMAIL, "google-email"), dependencyLogger);
|
||||
|
||||
failOpen.send(PII_NOTIFICATION);
|
||||
|
||||
String rendered =
|
||||
appender.list.stream()
|
||||
.filter(e -> e.getLevel() == Level.WARN)
|
||||
.findFirst()
|
||||
.orElseThrow()
|
||||
.getFormattedMessage();
|
||||
assertThat(rendered)
|
||||
.contains("dependency_name=\"google-email\"")
|
||||
.contains("dependency_type=\"notification\"")
|
||||
.contains("outcome=\"FAILURE\"")
|
||||
.contains("correlation_id=\"corr-email-1\"");
|
||||
assertThat(rendered).doesNotContain("secret@gmail.com").doesNotContain("secret-body-content");
|
||||
}
|
||||
|
||||
@Test
|
||||
void slackSendFailureIsFailOpenAndNeverRethrows() {
|
||||
FailOpenNotificationProvider failOpen =
|
||||
new FailOpenNotificationProvider(
|
||||
failingProvider(Channel.SLACK, "slack-webhook"), dependencyLogger);
|
||||
|
||||
assertThatCode(() -> failOpen.send(PII_NOTIFICATION)).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void slackSendFailureLogContainsProviderIdAndNoPii() {
|
||||
MDC.put(OutboundCorrelation.MDC_KEY, "corr-slack-1");
|
||||
FailOpenNotificationProvider failOpen =
|
||||
new FailOpenNotificationProvider(
|
||||
failingProvider(Channel.SLACK, "slack-webhook"), dependencyLogger);
|
||||
|
||||
failOpen.send(PII_NOTIFICATION);
|
||||
|
||||
String rendered =
|
||||
appender.list.stream()
|
||||
.filter(e -> e.getLevel() == Level.WARN)
|
||||
.findFirst()
|
||||
.orElseThrow()
|
||||
.getFormattedMessage();
|
||||
assertThat(rendered)
|
||||
.contains("dependency_name=\"slack-webhook\"")
|
||||
.contains("dependency_type=\"notification\"")
|
||||
.contains("correlation_id=\"corr-slack-1\"");
|
||||
assertThat(rendered).doesNotContain("secret@gmail.com").doesNotContain("secret-body-content");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Fail-fast (RoutingNotifier — router-fail-fast, no Disabled* sentinel)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void unboundRouteThrowsAdapterDisabledExceptionForEmail() {
|
||||
RoutingNotifier notifier = new RoutingNotifier(List.of(), Map.of());
|
||||
|
||||
assertThatThrownBy(() -> notifier.notify(Channel.EMAIL, PII_NOTIFICATION))
|
||||
.isInstanceOf(AdapterDisabledException.class)
|
||||
.extracting("adapterName")
|
||||
.isEqualTo("notification");
|
||||
}
|
||||
|
||||
@Test
|
||||
void unboundRouteThrowsAdapterDisabledExceptionForSlack() {
|
||||
RoutingNotifier notifier = new RoutingNotifier(List.of(), Map.of());
|
||||
|
||||
assertThatThrownBy(() -> notifier.notify(Channel.SLACK, "alerts", PII_NOTIFICATION))
|
||||
.isInstanceOf(AdapterDisabledException.class)
|
||||
.extracting("adapterName")
|
||||
.isEqualTo("notification");
|
||||
}
|
||||
}
|
||||
+343
@@ -0,0 +1,343 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.core;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import ch.qos.logback.classic.Level;
|
||||
import ch.qos.logback.classic.spi.ILoggingEvent;
|
||||
import ch.qos.logback.core.read.ListAppender;
|
||||
import dev.caskeleton.adapter.outbound.support.FailOpenDependencyLogger;
|
||||
import dev.caskeleton.adapter.outbound.support.OutboundCorrelation;
|
||||
import dev.caskeleton.application.notification.Channel;
|
||||
import dev.caskeleton.application.notification.Notification;
|
||||
import dev.caskeleton.shared.error.AdapterDisabledException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.slf4j.MDC;
|
||||
|
||||
/**
|
||||
* TDD tests for the multi-provider notification redesign: {@link RoutingNotifier} + {@link
|
||||
* FailOpenNotificationProvider}.
|
||||
*
|
||||
* <p>Behavior contracts (plan §4):
|
||||
*
|
||||
* <ul>
|
||||
* <li>default route → single provider send once.
|
||||
* <li>route=N providerIds → N sends (fan-out).
|
||||
* <li>one provider throws → others still send + failure logged (fail-open, no propagation).
|
||||
* <li>unbound route → {@link AdapterDisabledException}.
|
||||
* <li>duplicate providerId in channel → startup {@link IllegalStateException}.
|
||||
* <li>route references non-existent providerId → startup {@link IllegalStateException}.
|
||||
* <li>zero providers + zero routes → constructs cleanly.
|
||||
* <li>PII: {@link Notification} is NEVER passed to the dependency logger.
|
||||
* </ul>
|
||||
*
|
||||
* <p>{@link RoutingNotifier} accepts {@link FailOpenNotificationProvider} (typed registry — no
|
||||
* try/catch in the fan-out loop). Tests that need routing wrap raw stubs in {@link
|
||||
* FailOpenNotificationProvider} before passing to the constructor.
|
||||
*/
|
||||
class RoutingNotifierTest {
|
||||
|
||||
// PII markers placed in the notification to assert they never reach a log line.
|
||||
private static final Notification PII_NOTIFICATION =
|
||||
new Notification("secret@gmail.com", "subject", "secret-body-content");
|
||||
|
||||
private ch.qos.logback.classic.Logger logbackLogger;
|
||||
private ListAppender<ILoggingEvent> appender;
|
||||
private FailOpenDependencyLogger dependencyLogger;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
logbackLogger = (ch.qos.logback.classic.Logger) LoggerFactory.getLogger("test.notification");
|
||||
appender = new ListAppender<>();
|
||||
appender.start();
|
||||
logbackLogger.addAppender(appender);
|
||||
logbackLogger.setLevel(Level.DEBUG);
|
||||
dependencyLogger = new FailOpenDependencyLogger(logbackLogger);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
logbackLogger.detachAppender(appender);
|
||||
MDC.clear();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Stub helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private static NotificationProvider stubProvider(Channel channel, String providerId) {
|
||||
return new NotificationProvider() {
|
||||
@Override
|
||||
public Channel channel() {
|
||||
return channel;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String providerId() {
|
||||
return providerId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void send(Notification n) {
|
||||
/* no-op success */
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static NotificationProvider countingProvider(
|
||||
Channel channel, String providerId, AtomicInteger counter) {
|
||||
return new NotificationProvider() {
|
||||
@Override
|
||||
public Channel channel() {
|
||||
return channel;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String providerId() {
|
||||
return providerId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void send(Notification n) {
|
||||
counter.incrementAndGet();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static NotificationProvider failingProvider(Channel channel, String providerId) {
|
||||
return new NotificationProvider() {
|
||||
@Override
|
||||
public Channel channel() {
|
||||
return channel;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String providerId() {
|
||||
return providerId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void send(Notification n) throws Exception {
|
||||
throw new RuntimeException("provider-failure");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Wraps a raw provider in FailOpenNotificationProvider (mirrors NotificationConfig). */
|
||||
private FailOpenNotificationProvider failOpen(NotificationProvider provider) {
|
||||
return new FailOpenNotificationProvider(provider, dependencyLogger);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// FailOpenNotificationProvider tests
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void failOpenProviderSwallowsExceptionAndNeverRethrows() {
|
||||
FailOpenNotificationProvider failOpenProv =
|
||||
new FailOpenNotificationProvider(
|
||||
failingProvider(Channel.EMAIL, "google-email"), dependencyLogger);
|
||||
|
||||
assertThatCode(() -> failOpenProv.send(PII_NOTIFICATION)).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void failOpenProviderLogsFailureWithProviderIdAndNoPii() {
|
||||
MDC.put(OutboundCorrelation.MDC_KEY, "corr-1");
|
||||
FailOpenNotificationProvider failOpenProv =
|
||||
new FailOpenNotificationProvider(
|
||||
failingProvider(Channel.EMAIL, "google-email"), dependencyLogger);
|
||||
|
||||
failOpenProv.send(PII_NOTIFICATION);
|
||||
|
||||
String rendered =
|
||||
appender.list.stream()
|
||||
.filter(e -> e.getLevel() == Level.WARN)
|
||||
.findFirst()
|
||||
.orElseThrow()
|
||||
.getFormattedMessage();
|
||||
assertThat(rendered)
|
||||
.contains("dependency_name=\"google-email\"")
|
||||
.contains("dependency_type=\"notification\"")
|
||||
.contains("correlation_id=\"corr-1\"");
|
||||
// PII contract: notification body/recipient must never appear in logs.
|
||||
assertThat(rendered).doesNotContain("secret@gmail.com").doesNotContain("secret-body-content");
|
||||
}
|
||||
|
||||
@Test
|
||||
void failOpenProviderDelegatesChannelAndProviderId() {
|
||||
FailOpenNotificationProvider failOpenProv =
|
||||
new FailOpenNotificationProvider(
|
||||
stubProvider(Channel.SLACK, "slack-webhook"), dependencyLogger);
|
||||
|
||||
assertThat(failOpenProv.channel()).isEqualTo(Channel.SLACK);
|
||||
assertThat(failOpenProv.providerId()).isEqualTo("slack-webhook");
|
||||
}
|
||||
|
||||
@Test
|
||||
void failOpenProviderLogsSuccessOnSendWithNoPii() {
|
||||
FailOpenNotificationProvider failOpenProv =
|
||||
new FailOpenNotificationProvider(
|
||||
stubProvider(Channel.EMAIL, "google-email"), dependencyLogger);
|
||||
|
||||
failOpenProv.send(PII_NOTIFICATION);
|
||||
|
||||
boolean anyPii =
|
||||
appender.list.stream()
|
||||
.map(ILoggingEvent::getFormattedMessage)
|
||||
.anyMatch(m -> m.contains("secret@gmail.com") || m.contains("secret-body-content"));
|
||||
assertThat(anyPii).isFalse();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// RoutingNotifier tests
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void defaultRouteSendsToSingleProviderOnce() {
|
||||
AtomicInteger sendCount = new AtomicInteger();
|
||||
List<FailOpenNotificationProvider> providers =
|
||||
List.of(failOpen(countingProvider(Channel.EMAIL, "google-email", sendCount)));
|
||||
Map<Channel, Map<String, List<String>>> routes =
|
||||
Map.of(Channel.EMAIL, Map.of("default", List.of("google-email")));
|
||||
|
||||
RoutingNotifier notifier = new RoutingNotifier(providers, routes);
|
||||
notifier.notify(Channel.EMAIL, PII_NOTIFICATION);
|
||||
|
||||
assertThat(sendCount.get()).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void fanOutRouteSendsToAllProviders() {
|
||||
AtomicInteger sendA = new AtomicInteger();
|
||||
AtomicInteger sendB = new AtomicInteger();
|
||||
List<FailOpenNotificationProvider> providers =
|
||||
List.of(
|
||||
failOpen(countingProvider(Channel.EMAIL, "provider-a", sendA)),
|
||||
failOpen(countingProvider(Channel.EMAIL, "provider-b", sendB)));
|
||||
Map<Channel, Map<String, List<String>>> routes =
|
||||
Map.of(Channel.EMAIL, Map.of("alerts", List.of("provider-a", "provider-b")));
|
||||
|
||||
RoutingNotifier notifier = new RoutingNotifier(providers, routes);
|
||||
notifier.notify(Channel.EMAIL, "alerts", PII_NOTIFICATION);
|
||||
|
||||
assertThat(sendA.get()).isEqualTo(1);
|
||||
assertThat(sendB.get()).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void failingProviderDoesNotBlockRemainingFanOutSends() {
|
||||
AtomicInteger sendB = new AtomicInteger();
|
||||
// provider-a fails (fail-open wraps it), provider-b must still get called
|
||||
List<FailOpenNotificationProvider> wrapped =
|
||||
List.of(
|
||||
new FailOpenNotificationProvider(
|
||||
failingProvider(Channel.EMAIL, "provider-a"), dependencyLogger),
|
||||
new FailOpenNotificationProvider(
|
||||
countingProvider(Channel.EMAIL, "provider-b", sendB), dependencyLogger));
|
||||
Map<Channel, Map<String, List<String>>> routes =
|
||||
Map.of(Channel.EMAIL, Map.of("alerts", List.of("provider-a", "provider-b")));
|
||||
|
||||
RoutingNotifier notifier = new RoutingNotifier(wrapped, routes);
|
||||
assertThatCode(() -> notifier.notify(Channel.EMAIL, "alerts", PII_NOTIFICATION))
|
||||
.doesNotThrowAnyException();
|
||||
|
||||
assertThat(sendB.get()).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unboundRouteThrowsAdapterDisabledException() {
|
||||
RoutingNotifier notifier = new RoutingNotifier(List.of(), Map.of());
|
||||
|
||||
assertThatThrownBy(() -> notifier.notify(Channel.EMAIL, PII_NOTIFICATION))
|
||||
.isInstanceOf(AdapterDisabledException.class)
|
||||
.extracting("adapterName")
|
||||
.isEqualTo("notification");
|
||||
}
|
||||
|
||||
@Test
|
||||
void duplicateProviderIdWithinChannelFailsConstruction() {
|
||||
List<FailOpenNotificationProvider> providers =
|
||||
List.of(
|
||||
failOpen(stubProvider(Channel.EMAIL, "google-email")),
|
||||
failOpen(stubProvider(Channel.EMAIL, "google-email"))); // duplicate
|
||||
|
||||
assertThatThrownBy(() -> new RoutingNotifier(providers, Map.of()))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("duplicate")
|
||||
.hasMessageContaining("google-email");
|
||||
}
|
||||
|
||||
@Test
|
||||
void routeReferencingUnknownProviderFailsConstruction() {
|
||||
List<FailOpenNotificationProvider> providers = List.of(); // no providers
|
||||
Map<Channel, Map<String, List<String>>> routes =
|
||||
Map.of(Channel.EMAIL, Map.of("default", List.of("google-email")));
|
||||
|
||||
assertThatThrownBy(() -> new RoutingNotifier(providers, routes))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("google-email");
|
||||
}
|
||||
|
||||
@Test
|
||||
void zeroProvidersAndRoutesConstructsCleanly() {
|
||||
// L262: optional module must not block startup when unconfigured.
|
||||
assertThatCode(() -> new RoutingNotifier(List.of(), Map.of())).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void piiNeverReachesLogOnSendFailure() {
|
||||
List<FailOpenNotificationProvider> wrapped =
|
||||
List.of(
|
||||
new FailOpenNotificationProvider(
|
||||
failingProvider(Channel.SLACK, "slack-webhook"), dependencyLogger));
|
||||
Map<Channel, Map<String, List<String>>> routes =
|
||||
Map.of(Channel.SLACK, Map.of("default", List.of("slack-webhook")));
|
||||
|
||||
RoutingNotifier notifier = new RoutingNotifier(wrapped, routes);
|
||||
notifier.notify(Channel.SLACK, PII_NOTIFICATION);
|
||||
|
||||
boolean anyPii =
|
||||
appender.list.stream()
|
||||
.map(ILoggingEvent::getFormattedMessage)
|
||||
.anyMatch(m -> m.contains("secret@gmail.com") || m.contains("secret-body-content"));
|
||||
assertThat(anyPii).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void notifyWithExplicitRouteUsesNamedRoute() {
|
||||
List<String> sent = new ArrayList<>();
|
||||
NotificationProvider provider =
|
||||
new NotificationProvider() {
|
||||
@Override
|
||||
public Channel channel() {
|
||||
return Channel.SLACK;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String providerId() {
|
||||
return "slack-webhook";
|
||||
}
|
||||
|
||||
@Override
|
||||
public void send(Notification n) {
|
||||
sent.add(providerId());
|
||||
}
|
||||
};
|
||||
Map<Channel, Map<String, List<String>>> routes =
|
||||
Map.of(Channel.SLACK, Map.of("team-alerts", List.of("slack-webhook")));
|
||||
|
||||
RoutingNotifier notifier = new RoutingNotifier(List.of(failOpen(provider)), routes);
|
||||
notifier.notify(Channel.SLACK, "team-alerts", PII_NOTIFICATION);
|
||||
|
||||
assertThat(sent).containsExactly("slack-webhook");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user