5325 lines
255 KiB
Markdown
5325 lines
255 KiB
Markdown
# Notification Delivery Platform Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Java/Spring Backend Skeleton에 Request·Recipient·Attempt 수명주기, evidence 기반 Provider 결과, durable scheduling, callback ledger, SMTP·SES·Twilio·FCM·APNs·Web Push Stable Adapter, In-App Inbox, 보안·관측성·운영 검증을 갖춘 Notification Delivery Platform을 구현한다.
|
|
|
|
**Architecture:** `notification-core-api`가 N1 Typed API와 N2 Orchestration 계약을 소유하고, `notification-provider-spi`를 채널별 Adapter가 구현한다. 논리 요청은 `NotificationRequest → RecipientDelivery → DeliveryAttempt`로 분해하고 Provider callback은 append-only `ProviderEvent` 원장에 저장한 뒤 channel-specific projector가 `SubmissionOutcome`, `DeliveryOutcome`, `EvidenceLevel`, engagement·suppression fact를 갱신한다. `submit()` 성공은 durable acceptance만 뜻하며 `AMBIGUOUS` attempt의 자동 retry·fallback은 기본 차단한다.
|
|
|
|
**Tech Stack:** Java 21, Gradle Kotlin DSL, Spring Framework 6.2 common compatibility line with Spring 7.0 compatibility jobs, Spring Boot dependency management, PostgreSQL 16, JPA, Flyway, Spring JavaMail, existing HTTP Client Platform, Thymeleaf reference renderer, JSON Schema 2020-12, AES-256-GCM, HMAC-SHA-256, Micrometer, OpenTelemetry, JUnit 5, AssertJ, ArchUnit, Testcontainers, WireMock, Toxiproxy.
|
|
|
|
## Global Constraints
|
|
|
|
- Root package는 `io.backend.skeleton.notification`이다.
|
|
- 모듈 루트는 `modules/notification`이다.
|
|
- Core 공개 비동기 타입은 `CompletionStage`이며 Reactor 타입은 `notification-reactor`에서만 제공한다.
|
|
- `notification-core-api`, `notification-content-api`, `notification-contact-api`는 JPA, MVC, WebFlux, Provider SDK에 의존하지 않는다.
|
|
- 일반 애플리케이션은 N1 Typed API를 기본으로 사용한다.
|
|
- N2는 scheduling, cancel, ordered fallback, multi-recipient, opt-in dedup/collapse만 제공한다.
|
|
- N3는 typed provider capability만 노출하며 raw SDK client를 반환하지 않는다.
|
|
- N4 Admin Plane은 별도 authority, operation ID, reason, audit를 요구한다.
|
|
- `submit()` 성공은 DB에 논리 요청과 recipient delivery가 commit됐음을 의미하며 최종 전달을 뜻하지 않는다.
|
|
- SES MessageId, Twilio accepted/queued, FCM send success, APNs 2xx, Web Push 201을 `DELIVERED`로 매핑하지 않는다.
|
|
- Provider 결과를 확정할 수 없으면 `AMBIGUOUS`로 저장한다.
|
|
- `AMBIGUOUS` attempt가 있는 recipient에는 자동 retry와 cross-channel fallback을 기본 금지한다.
|
|
- Callback은 signature 검증 후 append-only ledger에 저장하고 projector를 실행한다.
|
|
- Callback 중복·역순·누락을 정상 failure mode로 처리한다.
|
|
- FCM primary target은 FID이며 registration token은 legacy compatibility type이다.
|
|
- Template ID, version, locale, normalized variables, rendered digest를 submit 시점에 고정한다.
|
|
- Idempotency, deduplication, collapse는 별도 기능이다.
|
|
- Stable scheduler는 PostgreSQL durable queue와 `FOR UPDATE SKIP LOCKED`를 사용한다.
|
|
- Provider 호출은 DB transaction 밖에서 수행하고 호출 전에 Attempt row를 commit한다.
|
|
- Contact Point 원문은 AES-256-GCM으로 암호화하고 equality lookup은 HMAC-SHA-256 fingerprint를 사용한다.
|
|
- HMAC key와 encryption key를 분리한다.
|
|
- metric label과 일반 로그에 recipient, 주소, token, notification ID, attempt ID, provider request ID, body를 기록하지 않는다.
|
|
- Web Push endpoint, p256dh, auth secret은 secret 수준으로 보호한다.
|
|
- Provider 인증 실패는 개별 메시지 retry가 아니라 Provider runtime health failure로 처리한다.
|
|
- In-App Inbox의 PostgreSQL row가 source of truth이며 WebSocket은 commit 이후 신호만 보낸다.
|
|
- Webhook extension은 기존 HTTP Client Platform을 재사용한다.
|
|
- 각 Task는 실패 테스트 작성 → 실패 확인 → 최소 구현 → 통과 확인 → 커밋 순서로 수행한다.
|
|
- 각 Task는 독립적으로 검토 가능한 하나의 커밋으로 종료한다.
|
|
- 테스트에 필요한 작은 fixture는 해당 test file 하단의 package-private type으로 작성한다. 공용 fixture만 `notification-testkit`으로 승격한다.
|
|
|
|
---
|
|
|
|
## 1. 확정 파일 구조
|
|
|
|
```text
|
|
backend-skeleton/
|
|
├── settings.gradle.kts
|
|
├── build-logic/src/main/kotlin/notification-library-conventions.gradle.kts
|
|
├── modules/notification/
|
|
│ ├── notification-core-api/
|
|
│ ├── notification-content-api/
|
|
│ ├── notification-template-api/
|
|
│ ├── notification-template-thymeleaf/
|
|
│ ├── notification-contact-api/
|
|
│ ├── notification-policy/
|
|
│ ├── notification-provider-spi/
|
|
│ ├── notification-persistence-jpa/
|
|
│ ├── notification-dispatch-runtime/
|
|
│ ├── notification-callback-api/
|
|
│ ├── notification-callback-mvc/
|
|
│ ├── notification-callback-webflux/
|
|
│ ├── notification-email-api/
|
|
│ ├── notification-email-smtp/
|
|
│ ├── notification-email-ses/
|
|
│ ├── notification-sms-api/
|
|
│ ├── notification-sms-twilio/
|
|
│ ├── notification-push-api/
|
|
│ ├── notification-push-fcm/
|
|
│ ├── notification-push-apns/
|
|
│ ├── notification-webpush/
|
|
│ ├── notification-inbox-api/
|
|
│ ├── notification-inbox-jpa/
|
|
│ ├── notification-webhook-extension/
|
|
│ ├── notification-observability/
|
|
│ ├── notification-security/
|
|
│ ├── notification-admin-api/
|
|
│ ├── notification-admin-runtime/
|
|
│ ├── notification-reactor/
|
|
│ ├── notification-spring-boot-starter/
|
|
│ └── notification-testkit/
|
|
├── infra/notification/
|
|
│ ├── postgres/
|
|
│ ├── smtp/
|
|
│ ├── wiremock/
|
|
│ ├── toxiproxy/
|
|
│ └── tls/
|
|
├── docs/notification/
|
|
│ ├── support-matrix.md
|
|
│ ├── configuration-reference.md
|
|
│ ├── delivery-evidence.md
|
|
│ ├── callback-reconciliation.md
|
|
│ ├── provider-runbooks.md
|
|
│ ├── security-privacy.md
|
|
│ ├── operations.md
|
|
│ └── migration-guide.md
|
|
└── docs/superpowers/specs/2026-08-10-notification-platform-design.md
|
|
```
|
|
|
|
## 2. 핵심 패키지
|
|
|
|
```text
|
|
io.backend.skeleton.notification.api
|
|
io.backend.skeleton.notification.api.content
|
|
io.backend.skeleton.notification.api.delivery
|
|
io.backend.skeleton.notification.api.error
|
|
io.backend.skeleton.notification.api.routing
|
|
io.backend.skeleton.notification.contact
|
|
io.backend.skeleton.notification.template
|
|
io.backend.skeleton.notification.policy
|
|
io.backend.skeleton.notification.provider
|
|
io.backend.skeleton.notification.persistence
|
|
io.backend.skeleton.notification.dispatch
|
|
io.backend.skeleton.notification.callback
|
|
io.backend.skeleton.notification.email
|
|
io.backend.skeleton.notification.sms
|
|
io.backend.skeleton.notification.push
|
|
io.backend.skeleton.notification.webpush
|
|
io.backend.skeleton.notification.inbox
|
|
io.backend.skeleton.notification.webhook
|
|
io.backend.skeleton.notification.observation
|
|
io.backend.skeleton.notification.security
|
|
io.backend.skeleton.notification.admin
|
|
io.backend.skeleton.notification.autoconfigure
|
|
io.backend.skeleton.notification.testkit
|
|
```
|
|
|
|
|
|
## 3. Module dependency map
|
|
|
|
Each module build file created in Task 1 must use the following direct project dependencies. Provider SDK and Spring web dependencies are added only in the owning adapter module.
|
|
|
|
```text
|
|
notification-core-api
|
|
→ no project dependency
|
|
|
|
notification-content-api
|
|
→ notification-core-api
|
|
|
|
notification-contact-api
|
|
→ notification-core-api
|
|
|
|
notification-template-api
|
|
→ notification-core-api
|
|
→ notification-content-api
|
|
|
|
notification-template-thymeleaf
|
|
→ notification-template-api
|
|
→ notification-content-api
|
|
|
|
notification-provider-spi
|
|
→ notification-core-api
|
|
→ notification-content-api
|
|
→ notification-contact-api
|
|
→ notification-template-api
|
|
|
|
notification-policy
|
|
→ notification-core-api
|
|
→ notification-contact-api
|
|
→ notification-provider-spi
|
|
|
|
notification-callback-api
|
|
→ notification-core-api
|
|
→ notification-provider-spi
|
|
|
|
notification-security
|
|
→ notification-core-api
|
|
→ notification-contact-api
|
|
|
|
notification-observability
|
|
→ notification-core-api
|
|
→ notification-provider-spi
|
|
|
|
notification-persistence-jpa
|
|
→ notification-core-api
|
|
→ notification-contact-api
|
|
→ notification-template-api
|
|
→ notification-callback-api
|
|
|
|
notification-dispatch-runtime
|
|
→ notification-core-api
|
|
→ notification-content-api
|
|
→ notification-contact-api
|
|
→ notification-template-api
|
|
→ notification-policy
|
|
→ notification-provider-spi
|
|
→ notification-callback-api
|
|
→ notification-persistence-jpa
|
|
→ notification-security
|
|
→ notification-observability
|
|
|
|
notification-callback-mvc / notification-callback-webflux
|
|
→ notification-callback-api
|
|
→ notification-security
|
|
|
|
notification-email-api / notification-sms-api / notification-push-api
|
|
→ notification-core-api
|
|
→ notification-content-api
|
|
→ notification-contact-api
|
|
|
|
notification-email-smtp
|
|
→ notification-email-api
|
|
→ notification-provider-spi
|
|
→ notification-security
|
|
|
|
notification-email-ses
|
|
→ notification-email-api
|
|
→ notification-provider-spi
|
|
→ httpclient platform
|
|
|
|
notification-sms-twilio
|
|
→ notification-sms-api
|
|
→ notification-provider-spi
|
|
→ httpclient platform
|
|
|
|
notification-push-fcm / notification-push-apns
|
|
→ notification-push-api
|
|
→ notification-provider-spi
|
|
→ notification-security
|
|
→ httpclient platform
|
|
|
|
notification-webpush
|
|
→ notification-core-api
|
|
→ notification-content-api
|
|
→ notification-contact-api
|
|
→ notification-provider-spi
|
|
→ notification-security
|
|
→ httpclient platform
|
|
|
|
notification-inbox-api
|
|
→ notification-core-api
|
|
→ notification-content-api
|
|
|
|
notification-inbox-jpa
|
|
→ notification-inbox-api
|
|
→ notification-persistence-jpa
|
|
→ optional messaging outbox integration
|
|
|
|
notification-webhook-extension
|
|
→ notification-provider-spi
|
|
→ httpclient platform
|
|
|
|
notification-admin-api
|
|
→ notification-core-api
|
|
|
|
notification-admin-runtime
|
|
→ notification-admin-api
|
|
→ notification-dispatch-runtime
|
|
→ notification-persistence-jpa
|
|
→ notification-observability
|
|
|
|
notification-reactor
|
|
→ notification-core-api
|
|
→ notification-dispatch-runtime
|
|
|
|
notification-spring-boot-starter
|
|
→ notification-dispatch-runtime
|
|
→ optional callback/provider/admin modules
|
|
|
|
notification-testkit
|
|
→ test fixtures from every Stable adapter
|
|
```
|
|
|
|
No reverse dependency from an API module to a concrete adapter is allowed.
|
|
|
|
---
|
|
|
|
### Task 1: Gradle 멀티모듈과 공통 품질 규칙 구성
|
|
|
|
**Files:**
|
|
- Modify: `settings.gradle.kts`
|
|
- Create: `build-logic/src/main/kotlin/notification-library-conventions.gradle.kts`
|
|
- Create: `modules/notification/notification-core-api/build.gradle.kts`
|
|
- Create: `modules/notification/notification-content-api/build.gradle.kts`
|
|
- Create: `modules/notification/notification-template-api/build.gradle.kts`
|
|
- Create: `modules/notification/notification-template-thymeleaf/build.gradle.kts`
|
|
- Create: `modules/notification/notification-contact-api/build.gradle.kts`
|
|
- Create: `modules/notification/notification-policy/build.gradle.kts`
|
|
- Create: `modules/notification/notification-provider-spi/build.gradle.kts`
|
|
- Create: `modules/notification/notification-persistence-jpa/build.gradle.kts`
|
|
- Create: `modules/notification/notification-dispatch-runtime/build.gradle.kts`
|
|
- Create: `modules/notification/notification-callback-api/build.gradle.kts`
|
|
- Create: `modules/notification/notification-callback-mvc/build.gradle.kts`
|
|
- Create: `modules/notification/notification-callback-webflux/build.gradle.kts`
|
|
- Create: `modules/notification/notification-email-api/build.gradle.kts`
|
|
- Create: `modules/notification/notification-email-smtp/build.gradle.kts`
|
|
- Create: `modules/notification/notification-email-ses/build.gradle.kts`
|
|
- Create: `modules/notification/notification-sms-api/build.gradle.kts`
|
|
- Create: `modules/notification/notification-sms-twilio/build.gradle.kts`
|
|
- Create: `modules/notification/notification-push-api/build.gradle.kts`
|
|
- Create: `modules/notification/notification-push-fcm/build.gradle.kts`
|
|
- Create: `modules/notification/notification-push-apns/build.gradle.kts`
|
|
- Create: `modules/notification/notification-webpush/build.gradle.kts`
|
|
- Create: `modules/notification/notification-inbox-api/build.gradle.kts`
|
|
- Create: `modules/notification/notification-inbox-jpa/build.gradle.kts`
|
|
- Create: `modules/notification/notification-webhook-extension/build.gradle.kts`
|
|
- Create: `modules/notification/notification-observability/build.gradle.kts`
|
|
- Create: `modules/notification/notification-security/build.gradle.kts`
|
|
- Create: `modules/notification/notification-admin-api/build.gradle.kts`
|
|
- Create: `modules/notification/notification-admin-runtime/build.gradle.kts`
|
|
- Create: `modules/notification/notification-reactor/build.gradle.kts`
|
|
- Create: `modules/notification/notification-spring-boot-starter/build.gradle.kts`
|
|
- Create: `modules/notification/notification-testkit/build.gradle.kts`
|
|
- Test: `modules/notification/notification-core-api/src/test/java/io/backend/skeleton/notification/api/ModuleSmokeTest.java`
|
|
|
|
**Interfaces:**
|
|
- Consumes: 없음.
|
|
- Produces: 후속 Task가 사용하는 31개 Gradle project path, Java 21 toolchain, JUnit Platform, dependency boundary.
|
|
|
|
**Implementation requirements:**
|
|
- 모든 module은 공통 conventions plugin을 적용한다.
|
|
- core/content/contact API build file에는 Spring, JPA, Provider SDK dependency를 추가하지 않는다.
|
|
- Experimental 기능은 Stable module classpath에 자동 포함하지 않는다.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```java
|
|
package io.backend.skeleton.notification.api;
|
|
|
|
import org.junit.jupiter.api.Test;
|
|
import static org.assertj.core.api.Assertions.assertThat;
|
|
|
|
class ModuleSmokeTest {
|
|
@Test
|
|
void coreModuleRunsOnJava21() {
|
|
assertThat(Runtime.version().feature()).isEqualTo(21);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `./gradlew :modules:notification:notification-core-api:test --tests "*.ModuleSmokeTest"`
|
|
|
|
Expected: FAIL: project path `:modules:notification:notification-core-api`가 존재하지 않는다.
|
|
|
|
- [ ] **Step 3: Write the minimal implementation**
|
|
|
|
Files to implement:
|
|
- `settings.gradle.kts`
|
|
- `build-logic/src/main/kotlin/notification-library-conventions.gradle.kts`
|
|
- `modules/notification/notification-core-api/build.gradle.kts`
|
|
- `modules/notification/notification-content-api/build.gradle.kts`
|
|
- `modules/notification/notification-template-api/build.gradle.kts`
|
|
- `modules/notification/notification-template-thymeleaf/build.gradle.kts`
|
|
- `modules/notification/notification-contact-api/build.gradle.kts`
|
|
- `modules/notification/notification-policy/build.gradle.kts`
|
|
- `modules/notification/notification-provider-spi/build.gradle.kts`
|
|
- `modules/notification/notification-persistence-jpa/build.gradle.kts`
|
|
- `modules/notification/notification-dispatch-runtime/build.gradle.kts`
|
|
- `modules/notification/notification-callback-api/build.gradle.kts`
|
|
- `modules/notification/notification-callback-mvc/build.gradle.kts`
|
|
- `modules/notification/notification-callback-webflux/build.gradle.kts`
|
|
- `modules/notification/notification-email-api/build.gradle.kts`
|
|
- `modules/notification/notification-email-smtp/build.gradle.kts`
|
|
- `modules/notification/notification-email-ses/build.gradle.kts`
|
|
- `modules/notification/notification-sms-api/build.gradle.kts`
|
|
- `modules/notification/notification-sms-twilio/build.gradle.kts`
|
|
- `modules/notification/notification-push-api/build.gradle.kts`
|
|
- `modules/notification/notification-push-fcm/build.gradle.kts`
|
|
- `modules/notification/notification-push-apns/build.gradle.kts`
|
|
- `modules/notification/notification-webpush/build.gradle.kts`
|
|
- `modules/notification/notification-inbox-api/build.gradle.kts`
|
|
- `modules/notification/notification-inbox-jpa/build.gradle.kts`
|
|
- `modules/notification/notification-webhook-extension/build.gradle.kts`
|
|
- `modules/notification/notification-observability/build.gradle.kts`
|
|
- `modules/notification/notification-security/build.gradle.kts`
|
|
- `modules/notification/notification-admin-api/build.gradle.kts`
|
|
- `modules/notification/notification-admin-runtime/build.gradle.kts`
|
|
- `modules/notification/notification-reactor/build.gradle.kts`
|
|
- `modules/notification/notification-spring-boot-starter/build.gradle.kts`
|
|
- `modules/notification/notification-testkit/build.gradle.kts`
|
|
|
|
```java
|
|
// settings.gradle.kts
|
|
val notificationModules = listOf(
|
|
"core-api", "content-api", "template-api", "template-thymeleaf",
|
|
"contact-api", "policy", "provider-spi", "persistence-jpa",
|
|
"dispatch-runtime", "callback-api", "callback-mvc", "callback-webflux",
|
|
"email-api", "email-smtp", "email-ses", "sms-api", "sms-twilio",
|
|
"push-api", "push-fcm", "push-apns", "webpush", "inbox-api",
|
|
"inbox-jpa", "webhook-extension", "observability", "security",
|
|
"admin-api", "admin-runtime", "reactor", "spring-boot-starter", "testkit"
|
|
)
|
|
notificationModules.forEach {
|
|
include(":modules:notification:notification-$it")
|
|
}
|
|
|
|
// notification-library-conventions.gradle.kts
|
|
plugins {
|
|
`java-library`
|
|
id("java-test-fixtures")
|
|
}
|
|
java { toolchain { languageVersion.set(JavaLanguageVersion.of(21)) } }
|
|
tasks.withType<Test>().configureEach { useJUnitPlatform() }
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `./gradlew :modules:notification:notification-core-api:test`
|
|
|
|
Expected: PASS: ModuleSmokeTest 1개 통과, Java 21 toolchain 사용.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add 'settings.gradle.kts' 'build-logic/src/main/kotlin/notification-library-conventions.gradle.kts' 'modules/notification/notification-core-api/build.gradle.kts' 'modules/notification/notification-content-api/build.gradle.kts' 'modules/notification/notification-template-api/build.gradle.kts' 'modules/notification/notification-template-thymeleaf/build.gradle.kts' 'modules/notification/notification-contact-api/build.gradle.kts' 'modules/notification/notification-policy/build.gradle.kts' 'modules/notification/notification-provider-spi/build.gradle.kts' 'modules/notification/notification-persistence-jpa/build.gradle.kts' 'modules/notification/notification-dispatch-runtime/build.gradle.kts' 'modules/notification/notification-callback-api/build.gradle.kts' 'modules/notification/notification-callback-mvc/build.gradle.kts' 'modules/notification/notification-callback-webflux/build.gradle.kts' 'modules/notification/notification-email-api/build.gradle.kts' 'modules/notification/notification-email-smtp/build.gradle.kts' 'modules/notification/notification-email-ses/build.gradle.kts' 'modules/notification/notification-sms-api/build.gradle.kts' 'modules/notification/notification-sms-twilio/build.gradle.kts' 'modules/notification/notification-push-api/build.gradle.kts' 'modules/notification/notification-push-fcm/build.gradle.kts' 'modules/notification/notification-push-apns/build.gradle.kts' 'modules/notification/notification-webpush/build.gradle.kts' 'modules/notification/notification-inbox-api/build.gradle.kts' 'modules/notification/notification-inbox-jpa/build.gradle.kts' 'modules/notification/notification-webhook-extension/build.gradle.kts' 'modules/notification/notification-observability/build.gradle.kts' 'modules/notification/notification-security/build.gradle.kts' 'modules/notification/notification-admin-api/build.gradle.kts' 'modules/notification/notification-admin-runtime/build.gradle.kts' 'modules/notification/notification-reactor/build.gradle.kts' 'modules/notification/notification-spring-boot-starter/build.gradle.kts' 'modules/notification/notification-testkit/build.gradle.kts' 'modules/notification/notification-core-api/src/test/java/io/backend/skeleton/notification/api/ModuleSmokeTest.java'
|
|
git commit -m "build(notification): create delivery platform modules"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: 핵심 식별자와 증거 Enum 구현
|
|
|
|
**Files:**
|
|
- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/NotificationId.java`
|
|
- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/RecipientDeliveryId.java`
|
|
- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/DeliveryAttemptId.java`
|
|
- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/ProviderEventId.java`
|
|
- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/ContactPointId.java`
|
|
- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/ProviderProfileId.java`
|
|
- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/ProviderId.java`
|
|
- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/TenantId.java`
|
|
- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/CorrelationId.java`
|
|
- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/IdempotencyKey.java`
|
|
- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/RequestStatus.java`
|
|
- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/delivery/SubmissionOutcome.java`
|
|
- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/delivery/DeliveryOutcome.java`
|
|
- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/delivery/AttemptConfirmation.java`
|
|
- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/delivery/EvidenceLevel.java`
|
|
- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/delivery/RecipientDeliveryState.java`
|
|
- Test: `modules/notification/notification-core-api/src/test/java/io/backend/skeleton/notification/api/delivery/EvidenceModelTest.java`
|
|
|
|
**Interfaces:**
|
|
- Consumes: Task 1의 core-api module.
|
|
- Produces: `NotificationId`, `RecipientDeliveryId`, `DeliveryAttemptId`, `SubmissionOutcome`, `DeliveryOutcome`, `AttemptConfirmation`, `EvidenceLevel`, `RecipientDeliveryState`.
|
|
|
|
**Implementation requirements:**
|
|
- EvidenceLevel ordinal로 Provider event merge를 구현하지 않는다.
|
|
- Notification ID는 UUIDv7 generator를 후속 runtime Task에서 주입한다.
|
|
- `DELIVERED=true`, `EXACTLY_ONCE` 같은 boolean 보장 타입을 만들지 않는다.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```java
|
|
package io.backend.skeleton.notification.api.delivery;
|
|
|
|
import org.junit.jupiter.api.Test;
|
|
import java.util.UUID;
|
|
import io.backend.skeleton.notification.api.NotificationId;
|
|
import static org.assertj.core.api.Assertions.*;
|
|
|
|
class EvidenceModelTest {
|
|
@Test
|
|
void providerAcceptanceIsNotDelivery() {
|
|
assertThat(EvidenceLevel.PROVIDER_ACCEPTED)
|
|
.isNotEqualTo(EvidenceLevel.DEVICE_DELIVERED);
|
|
assertThat(SubmissionOutcome.CONFIRMED_ACCEPTED.name())
|
|
.doesNotContain("DELIVERED");
|
|
}
|
|
|
|
@Test
|
|
void idsRejectNull() {
|
|
assertThatThrownBy(() -> new NotificationId(null))
|
|
.isInstanceOf(NullPointerException.class);
|
|
}
|
|
|
|
@Test
|
|
void ambiguousIsFirstClassOutcome() {
|
|
assertThat(SubmissionOutcome.values())
|
|
.contains(SubmissionOutcome.AMBIGUOUS);
|
|
assertThat(AttemptConfirmation.values())
|
|
.contains(AttemptConfirmation.AMBIGUOUS);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `./gradlew :modules:notification:notification-core-api:test --tests "*.EvidenceModelTest"`
|
|
|
|
Expected: FAIL: NotificationId와 evidence enum이 정의되지 않았다.
|
|
|
|
- [ ] **Step 3: Write the minimal implementation**
|
|
|
|
Files to implement:
|
|
- `NotificationId.java`
|
|
- `RecipientDeliveryId.java`
|
|
- `DeliveryAttemptId.java`
|
|
- `ProviderEventId.java`
|
|
- `ContactPointId.java`
|
|
- `ProviderProfileId.java`
|
|
- `ProviderId.java`
|
|
- `TenantId.java`
|
|
- `CorrelationId.java`
|
|
- `IdempotencyKey.java`
|
|
- `RequestStatus.java`
|
|
- `SubmissionOutcome.java`
|
|
- `DeliveryOutcome.java`
|
|
- `AttemptConfirmation.java`
|
|
- `EvidenceLevel.java`
|
|
- `RecipientDeliveryState.java`
|
|
|
|
```java
|
|
public record NotificationId(UUID value) {
|
|
public NotificationId {
|
|
java.util.Objects.requireNonNull(value, "value");
|
|
}
|
|
}
|
|
|
|
public record TenantId(String value) {
|
|
public TenantId {
|
|
if (value == null || value.isBlank()) throw new IllegalArgumentException("value");
|
|
}
|
|
}
|
|
|
|
public enum RequestStatus {
|
|
CREATED, VALIDATED, SCHEDULED, PROCESSING,
|
|
PARTIALLY_COMPLETED, COMPLETED, CANCELED, EXPIRED, FAILED
|
|
}
|
|
|
|
public enum SubmissionOutcome {
|
|
NOT_SUBMITTED, CONFIRMED_ACCEPTED, CONFIRMED_REJECTED, AMBIGUOUS
|
|
}
|
|
|
|
public enum DeliveryOutcome {
|
|
UNKNOWN, SENT, DELIVERED, UNDELIVERED, BOUNCED, EXPIRED
|
|
}
|
|
|
|
public enum AttemptConfirmation {
|
|
CONFIRMED, REJECTED, AMBIGUOUS
|
|
}
|
|
|
|
public enum EvidenceLevel {
|
|
NONE,
|
|
PLATFORM_QUEUED,
|
|
PROVIDER_ACCEPTED,
|
|
NETWORK_OR_CARRIER_ACCEPTED,
|
|
DEVICE_DELIVERED,
|
|
USER_AGENT_DISPLAYED,
|
|
USER_READ
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `./gradlew :modules:notification:notification-core-api:test`
|
|
|
|
Expected: PASS: evidence 단계가 분리되고 null ID가 거부된다.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/NotificationId.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/RecipientDeliveryId.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/DeliveryAttemptId.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/ProviderEventId.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/ContactPointId.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/ProviderProfileId.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/ProviderId.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/TenantId.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/CorrelationId.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/IdempotencyKey.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/RequestStatus.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/delivery/SubmissionOutcome.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/delivery/DeliveryOutcome.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/delivery/AttemptConfirmation.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/delivery/EvidenceLevel.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/delivery/RecipientDeliveryState.java' 'modules/notification/notification-core-api/src/test/java/io/backend/skeleton/notification/api/delivery/EvidenceModelTest.java'
|
|
git commit -m "feat(notification): add identity and evidence model"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: 채널별 NotificationContent sealed hierarchy 구현
|
|
|
|
**Files:**
|
|
- Create: `modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/NotificationContent.java`
|
|
- Create: `modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/EmailContent.java`
|
|
- Create: `modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/SmsContent.java`
|
|
- Create: `modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/MobilePushContent.java`
|
|
- Create: `modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/WebPushContent.java`
|
|
- Create: `modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/InAppContent.java`
|
|
- Create: `modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/AttachmentRef.java`
|
|
- Create: `modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/EmailOptions.java`
|
|
- Create: `modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/SmsOptions.java`
|
|
- Create: `modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/PushPresentation.java`
|
|
- Create: `modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/WebPushOptions.java`
|
|
- Create: `modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/InAppAction.java`
|
|
- Create: `modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/AttachmentDisposition.java`
|
|
- Test: `modules/notification/notification-content-api/src/test/java/io/backend/skeleton/notification/api/content/NotificationContentContractTest.java`
|
|
|
|
**Interfaces:**
|
|
- Consumes: Task 1 module structure.
|
|
- Produces: Provider SDK와 raw credential이 없는 typed content records와 attachment reference.
|
|
|
|
**Implementation requirements:**
|
|
- Email, SMS, Push 고유 필드를 하나의 거대 record로 합치지 않는다.
|
|
- Map은 push data처럼 문자열 key/value가 표준인 위치에만 제한 사용한다.
|
|
- attachment는 fileserver/objectstorage reference만 보유한다.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```java
|
|
package io.backend.skeleton.notification.api.content;
|
|
|
|
import org.junit.jupiter.api.Test;
|
|
import java.net.URI;
|
|
import java.util.Map;
|
|
import static org.assertj.core.api.Assertions.*;
|
|
|
|
class NotificationContentContractTest {
|
|
@Test
|
|
void pushDataRejectsNullKeyAndValue() {
|
|
assertThatThrownBy(() -> new MobilePushContent(
|
|
"title", "body", URI.create("https://app.example/item/1"),
|
|
Map.of("key", (String) null), PushPresentation.DEFAULT))
|
|
.isInstanceOf(NullPointerException.class);
|
|
}
|
|
|
|
@Test
|
|
void attachmentCarriesReferenceNotBytes() {
|
|
var ref = new AttachmentRef("file:01", "report.pdf",
|
|
"application/pdf", 1024, "sha256:abc", AttachmentDisposition.ATTACHMENT);
|
|
assertThat(ref.contentReference()).isEqualTo("file:01");
|
|
assertThat(ref.getClass().getRecordComponents())
|
|
.noneMatch(c -> c.getType().equals(byte[].class));
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `./gradlew :modules:notification:notification-content-api:test --tests "*.NotificationContentContractTest"`
|
|
|
|
Expected: FAIL: content records와 AttachmentRef가 존재하지 않는다.
|
|
|
|
- [ ] **Step 3: Write the minimal implementation**
|
|
|
|
Files to implement:
|
|
- `NotificationContent.java`
|
|
- `EmailContent.java`
|
|
- `SmsContent.java`
|
|
- `MobilePushContent.java`
|
|
- `WebPushContent.java`
|
|
- `InAppContent.java`
|
|
- `AttachmentRef.java`
|
|
- `EmailOptions.java`
|
|
- `SmsOptions.java`
|
|
- `PushPresentation.java`
|
|
- `WebPushOptions.java`
|
|
- `InAppAction.java`
|
|
- `AttachmentDisposition.java`
|
|
|
|
```java
|
|
public sealed interface NotificationContent
|
|
permits EmailContent, SmsContent, MobilePushContent, WebPushContent, InAppContent {}
|
|
|
|
public record PushPresentation(
|
|
java.util.Optional<String> sound,
|
|
java.util.Optional<Integer> badge
|
|
) {
|
|
public static final PushPresentation DEFAULT =
|
|
new PushPresentation(java.util.Optional.empty(), java.util.Optional.empty());
|
|
}
|
|
|
|
public record SmsContent(String text, SmsOptions options)
|
|
implements NotificationContent {
|
|
public SmsContent {
|
|
if (text == null || text.isBlank()) throw new IllegalArgumentException("text");
|
|
java.util.Objects.requireNonNull(options, "options");
|
|
}
|
|
}
|
|
|
|
public record AttachmentRef(
|
|
String contentReference,
|
|
String displayName,
|
|
String contentType,
|
|
long expectedSize,
|
|
String expectedDigest,
|
|
AttachmentDisposition disposition
|
|
) {
|
|
public AttachmentRef {
|
|
if (expectedSize < 0) throw new IllegalArgumentException("expectedSize");
|
|
java.util.Objects.requireNonNull(contentReference, "contentReference");
|
|
java.util.Objects.requireNonNull(expectedDigest, "expectedDigest");
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `./gradlew :modules:notification:notification-content-api:test`
|
|
|
|
Expected: PASS: 각 채널 content가 typed record이고 attachment bytes가 공개 모델에 없다.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add 'modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/NotificationContent.java' 'modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/EmailContent.java' 'modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/SmsContent.java' 'modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/MobilePushContent.java' 'modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/WebPushContent.java' 'modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/InAppContent.java' 'modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/AttachmentRef.java' 'modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/EmailOptions.java' 'modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/SmsOptions.java' 'modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/PushPresentation.java' 'modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/WebPushOptions.java' 'modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/InAppAction.java' 'modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/AttachmentDisposition.java' 'modules/notification/notification-content-api/src/test/java/io/backend/skeleton/notification/api/content/NotificationContentContractTest.java'
|
|
git commit -m "feat(notification): add typed channel content model"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: NotificationPlan과 Routing Strategy 구현
|
|
|
|
**Files:**
|
|
- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/NotificationPlan.java`
|
|
- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/RecipientSpec.java`
|
|
- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/TemplateSelection.java`
|
|
- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/DeduplicationSpec.java`
|
|
- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/DeduplicationAction.java`
|
|
- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/CollapseSpec.java`
|
|
- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/CollapseScope.java`
|
|
- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/ContactPointSelector.java`
|
|
- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/ChannelPreferenceOverride.java`
|
|
- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/routing/DeliveryStrategy.java`
|
|
- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/routing/ExplicitChannel.java`
|
|
- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/routing/OrderedFallback.java`
|
|
- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/routing/Channel.java`
|
|
- Test: `modules/notification/notification-core-api/src/test/java/io/backend/skeleton/notification/api/NotificationPlanTest.java`
|
|
|
|
**Interfaces:**
|
|
- Consumes: Task 2 IDs와 Task 3 content API.
|
|
- Produces: `NotificationPlan`, `RecipientSpec`, `DeliveryStrategy`, Stable `ExplicitChannel`·`OrderedFallback`.
|
|
|
|
**Implementation requirements:**
|
|
- Parallel first-success는 이 Task의 public sealed hierarchy에 넣지 않는다.
|
|
- metadata key/value hard limit validation을 NotificationPlan constructor에 적용한다.
|
|
- recipient 목록은 immutable copy로 보존한다.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```java
|
|
package io.backend.skeleton.notification.api;
|
|
|
|
import org.junit.jupiter.api.Test;
|
|
import java.time.Instant;
|
|
import java.util.List;
|
|
import io.backend.skeleton.notification.api.routing.*;
|
|
import static org.assertj.core.api.Assertions.*;
|
|
|
|
class NotificationPlanTest {
|
|
@Test
|
|
void orderedFallbackRequiresDistinctChannels() {
|
|
assertThatThrownBy(() -> new OrderedFallback(
|
|
List.of(Channel.PUSH, Channel.PUSH)))
|
|
.isInstanceOf(IllegalArgumentException.class);
|
|
}
|
|
|
|
@Test
|
|
void expiryMustBeAfterNotBefore() {
|
|
var now = Instant.parse("2026-08-10T00:00:00Z");
|
|
assertThatThrownBy(() -> NotificationPlanFixture.plan(now.plusSeconds(60), now))
|
|
.isInstanceOf(IllegalArgumentException.class);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `./gradlew :modules:notification:notification-core-api:test --tests "*.NotificationPlanTest"`
|
|
|
|
Expected: FAIL: NotificationPlan과 routing types가 없다.
|
|
|
|
- [ ] **Step 3: Write the minimal implementation**
|
|
|
|
Files to implement:
|
|
- `NotificationPlan.java`
|
|
- `RecipientSpec.java`
|
|
- `TemplateSelection.java`
|
|
- `DeduplicationSpec.java`
|
|
- `DeduplicationAction.java`
|
|
- `CollapseSpec.java`
|
|
- `CollapseScope.java`
|
|
- `ContactPointSelector.java`
|
|
- `ChannelPreferenceOverride.java`
|
|
- `DeliveryStrategy.java`
|
|
- `ExplicitChannel.java`
|
|
- `OrderedFallback.java`
|
|
- `Channel.java`
|
|
|
|
```java
|
|
public record TemplateSelection(String templateId, long version, java.util.Locale locale) {
|
|
public TemplateSelection {
|
|
if (templateId == null || templateId.isBlank()) throw new IllegalArgumentException("templateId");
|
|
if (version <= 0) throw new IllegalArgumentException("version");
|
|
java.util.Objects.requireNonNull(locale, "locale");
|
|
}
|
|
}
|
|
|
|
public record DeduplicationSpec(
|
|
String dedupKey,
|
|
java.time.Duration window,
|
|
DeduplicationAction action
|
|
) {}
|
|
|
|
public record CollapseSpec(String key, CollapseScope scope) {}
|
|
|
|
public sealed interface DeliveryStrategy permits ExplicitChannel, OrderedFallback {}
|
|
|
|
public record ExplicitChannel(Channel channel) implements DeliveryStrategy {
|
|
public ExplicitChannel { java.util.Objects.requireNonNull(channel, "channel"); }
|
|
}
|
|
|
|
public record OrderedFallback(java.util.List<Channel> channels)
|
|
implements DeliveryStrategy {
|
|
public OrderedFallback {
|
|
channels = java.util.List.copyOf(channels);
|
|
if (channels.isEmpty() || new java.util.HashSet<>(channels).size() != channels.size()) {
|
|
throw new IllegalArgumentException("channels must be non-empty and distinct");
|
|
}
|
|
}
|
|
}
|
|
|
|
// NotificationPlan compact constructor
|
|
if (expiresAt.isPresent() && notBefore.isPresent()
|
|
&& !expiresAt.get().isAfter(notBefore.get())) {
|
|
throw new IllegalArgumentException("expiresAt must be after notBefore");
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `./gradlew :modules:notification:notification-core-api:test`
|
|
|
|
Expected: PASS: Stable routing strategy와 시간 불변 조건이 검증된다.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/NotificationPlan.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/RecipientSpec.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/TemplateSelection.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/DeduplicationSpec.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/DeduplicationAction.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/CollapseSpec.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/CollapseScope.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/ContactPointSelector.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/ChannelPreferenceOverride.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/routing/DeliveryStrategy.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/routing/ExplicitChannel.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/routing/OrderedFallback.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/routing/Channel.java' 'modules/notification/notification-core-api/src/test/java/io/backend/skeleton/notification/api/NotificationPlanTest.java'
|
|
git commit -m "feat(notification): add notification plan and routing strategies"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: N1 Typed Facade와 N2 Orchestrator 계약 구현
|
|
|
|
**Files:**
|
|
- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/NotificationReceipt.java`
|
|
- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/NotificationOrchestrator.java`
|
|
- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/CancelResult.java`
|
|
- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/CancelCommand.java`
|
|
- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/NotificationSnapshot.java`
|
|
- Create: `modules/notification/notification-email-api/src/main/java/io/backend/skeleton/notification/email/EmailNotification.java`
|
|
- Create: `modules/notification/notification-sms-api/src/main/java/io/backend/skeleton/notification/sms/SmsNotification.java`
|
|
- Create: `modules/notification/notification-sms-api/src/main/java/io/backend/skeleton/notification/sms/SmsEstimate.java`
|
|
- Create: `modules/notification/notification-sms-api/src/main/java/io/backend/skeleton/notification/sms/SmsEncoding.java`
|
|
- Create: `modules/notification/notification-push-api/src/main/java/io/backend/skeleton/notification/push/MobilePushNotification.java`
|
|
- Create: `modules/notification/notification-webpush/src/main/java/io/backend/skeleton/notification/webpush/WebPushNotification.java`
|
|
- Create: `modules/notification/notification-email-api/src/main/java/io/backend/skeleton/notification/email/EmailNotifier.java`
|
|
- Create: `modules/notification/notification-sms-api/src/main/java/io/backend/skeleton/notification/sms/SmsNotifier.java`
|
|
- Create: `modules/notification/notification-push-api/src/main/java/io/backend/skeleton/notification/push/MobilePushNotifier.java`
|
|
- Create: `modules/notification/notification-webpush/src/main/java/io/backend/skeleton/notification/webpush/WebPushNotifier.java`
|
|
- Test: `modules/notification/notification-core-api/src/test/java/io/backend/skeleton/notification/api/PublicApiBoundaryTest.java`
|
|
|
|
**Interfaces:**
|
|
- Consumes: Task 2 IDs, Task 4 NotificationPlan.
|
|
- Produces: N1 channel facade, N2 `submit/schedule/cancel/get`, durable acceptance만 표현하는 `NotificationReceipt`.
|
|
|
|
**Implementation requirements:**
|
|
- N1 facade 구현은 후속 starter/runtime가 제공한다.
|
|
- N1 메서드는 provider response를 대기하지 않는다.
|
|
- public API에서 Provider SDK와 raw map request를 사용하지 않는다.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```java
|
|
package io.backend.skeleton.notification.api;
|
|
|
|
import org.junit.jupiter.api.Test;
|
|
import java.lang.reflect.Method;
|
|
import static org.assertj.core.api.Assertions.*;
|
|
|
|
class PublicApiBoundaryTest {
|
|
@Test
|
|
void receiptContainsNoDeliveryBoolean() {
|
|
assertThat(NotificationReceipt.class.getRecordComponents())
|
|
.extracting(c -> c.getName())
|
|
.doesNotContain("delivered", "sent", "read");
|
|
}
|
|
|
|
@Test
|
|
void orchestratorReturnsDurableReceipt() throws Exception {
|
|
Method method = NotificationOrchestrator.class
|
|
.getMethod("submit", NotificationPlan.class);
|
|
assertThat(method.getReturnType()).isEqualTo(NotificationReceipt.class);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `./gradlew :modules:notification:notification-core-api:test --tests "*.PublicApiBoundaryTest"`
|
|
|
|
Expected: FAIL: public facade와 receipt가 없다.
|
|
|
|
- [ ] **Step 3: Write the minimal implementation**
|
|
|
|
Files to implement:
|
|
- `NotificationReceipt.java`
|
|
- `NotificationOrchestrator.java`
|
|
- `CancelResult.java`
|
|
- `CancelCommand.java`
|
|
- `NotificationSnapshot.java`
|
|
- `EmailNotification.java`
|
|
- `SmsNotification.java`
|
|
- `SmsEstimate.java`
|
|
- `SmsEncoding.java`
|
|
- `MobilePushNotification.java`
|
|
- `WebPushNotification.java`
|
|
- `EmailNotifier.java`
|
|
- `SmsNotifier.java`
|
|
- `MobilePushNotifier.java`
|
|
- `WebPushNotifier.java`
|
|
|
|
```java
|
|
public record NotificationReceipt(
|
|
NotificationId notificationId,
|
|
RequestStatus status,
|
|
java.time.Instant acceptedAt
|
|
) {
|
|
public NotificationReceipt {
|
|
java.util.Objects.requireNonNull(notificationId, "notificationId");
|
|
java.util.Objects.requireNonNull(status, "status");
|
|
java.util.Objects.requireNonNull(acceptedAt, "acceptedAt");
|
|
}
|
|
}
|
|
|
|
public record SmsEstimate(
|
|
SmsEncoding encoding,
|
|
int segmentCount,
|
|
int encodedLength,
|
|
boolean exceedsRecommendedLimit
|
|
) {}
|
|
|
|
public interface NotificationOrchestrator {
|
|
NotificationReceipt submit(NotificationPlan plan);
|
|
NotificationReceipt schedule(NotificationPlan plan, java.time.Instant scheduleAt);
|
|
CancelResult cancel(NotificationId notificationId, CancelCommand command);
|
|
NotificationSnapshot get(NotificationId notificationId);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `./gradlew :modules:notification:notification-core-api:test`
|
|
|
|
Expected: PASS: receipt가 final delivery를 표현하지 않고 public API가 typed contract다.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/NotificationReceipt.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/NotificationOrchestrator.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/CancelResult.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/CancelCommand.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/NotificationSnapshot.java' 'modules/notification/notification-email-api/src/main/java/io/backend/skeleton/notification/email/EmailNotification.java' 'modules/notification/notification-sms-api/src/main/java/io/backend/skeleton/notification/sms/SmsNotification.java' 'modules/notification/notification-sms-api/src/main/java/io/backend/skeleton/notification/sms/SmsEstimate.java' 'modules/notification/notification-sms-api/src/main/java/io/backend/skeleton/notification/sms/SmsEncoding.java' 'modules/notification/notification-push-api/src/main/java/io/backend/skeleton/notification/push/MobilePushNotification.java' 'modules/notification/notification-webpush/src/main/java/io/backend/skeleton/notification/webpush/WebPushNotification.java' 'modules/notification/notification-email-api/src/main/java/io/backend/skeleton/notification/email/EmailNotifier.java' 'modules/notification/notification-sms-api/src/main/java/io/backend/skeleton/notification/sms/SmsNotifier.java' 'modules/notification/notification-push-api/src/main/java/io/backend/skeleton/notification/push/MobilePushNotifier.java' 'modules/notification/notification-webpush/src/main/java/io/backend/skeleton/notification/webpush/WebPushNotifier.java' 'modules/notification/notification-core-api/src/test/java/io/backend/skeleton/notification/api/PublicApiBoundaryTest.java'
|
|
git commit -m "feat(notification): add typed public notification APIs"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6: Contact Point 타입과 FCM FID 우선 모델 구현
|
|
|
|
**Files:**
|
|
- Create: `modules/notification/notification-contact-api/src/main/java/io/backend/skeleton/notification/contact/ContactPointValue.java`
|
|
- Create: `modules/notification/notification-contact-api/src/main/java/io/backend/skeleton/notification/contact/EmailAddress.java`
|
|
- Create: `modules/notification/notification-contact-api/src/main/java/io/backend/skeleton/notification/contact/PhoneNumber.java`
|
|
- Create: `modules/notification/notification-contact-api/src/main/java/io/backend/skeleton/notification/contact/MobilePushTarget.java`
|
|
- Create: `modules/notification/notification-contact-api/src/main/java/io/backend/skeleton/notification/contact/FcmInstallationId.java`
|
|
- Create: `modules/notification/notification-contact-api/src/main/java/io/backend/skeleton/notification/contact/LegacyFcmRegistrationToken.java`
|
|
- Create: `modules/notification/notification-contact-api/src/main/java/io/backend/skeleton/notification/contact/ApnsDeviceToken.java`
|
|
- Create: `modules/notification/notification-contact-api/src/main/java/io/backend/skeleton/notification/contact/ApnsEnvironment.java`
|
|
- Create: `modules/notification/notification-contact-api/src/main/java/io/backend/skeleton/notification/contact/ContactPointStatus.java`
|
|
- Test: `modules/notification/notification-contact-api/src/test/java/io/backend/skeleton/notification/contact/ContactPointTypeTest.java`
|
|
|
|
**Interfaces:**
|
|
- Consumes: Task 1 contact-api module.
|
|
- Produces: Typed contact values, FID primary와 legacy token의 명시적 분리, APNs environment.
|
|
|
|
**Implementation requirements:**
|
|
- Email local-part case 정책은 보존하고 domain만 IDNA/lowercase normalize한다.
|
|
- PhoneNumber E.164 구체 검증은 Task 30에서 강화한다.
|
|
- Contact Point type에 provider credential을 넣지 않는다.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```java
|
|
package io.backend.skeleton.notification.contact;
|
|
|
|
import org.junit.jupiter.api.Test;
|
|
import static org.assertj.core.api.Assertions.*;
|
|
|
|
class ContactPointTypeTest {
|
|
@Test
|
|
void fcmInstallationAndLegacyTokenAreDifferentTypes() {
|
|
assertThat(FcmInstallationId.class)
|
|
.isNotEqualTo(LegacyFcmRegistrationToken.class);
|
|
}
|
|
|
|
@Test
|
|
void apnsTokenRequiresEnvironment() {
|
|
assertThatThrownBy(() -> new ApnsDeviceToken("token", null))
|
|
.isInstanceOf(NullPointerException.class);
|
|
}
|
|
|
|
@Test
|
|
void emailIsNormalizedWithoutChangingOriginalDisplay() {
|
|
var address = EmailAddress.parse("User@Example.COM");
|
|
assertThat(address.normalized()).isEqualTo("User@example.com");
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `./gradlew :modules:notification:notification-contact-api:test --tests "*.ContactPointTypeTest"`
|
|
|
|
Expected: FAIL: typed Contact Point가 없다.
|
|
|
|
- [ ] **Step 3: Write the minimal implementation**
|
|
|
|
Files to implement:
|
|
- `ContactPointValue.java`
|
|
- `EmailAddress.java`
|
|
- `PhoneNumber.java`
|
|
- `MobilePushTarget.java`
|
|
- `FcmInstallationId.java`
|
|
- `LegacyFcmRegistrationToken.java`
|
|
- `ApnsDeviceToken.java`
|
|
- `ApnsEnvironment.java`
|
|
- `ContactPointStatus.java`
|
|
|
|
```java
|
|
public sealed interface MobilePushTarget extends ContactPointValue
|
|
permits FcmInstallationId, LegacyFcmRegistrationToken, ApnsDeviceToken {}
|
|
|
|
public record FcmInstallationId(String value) implements MobilePushTarget {
|
|
public FcmInstallationId {
|
|
if (value == null || value.isBlank()) throw new IllegalArgumentException("value");
|
|
}
|
|
}
|
|
|
|
public record LegacyFcmRegistrationToken(String value) implements MobilePushTarget {}
|
|
|
|
public record ApnsDeviceToken(String value, ApnsEnvironment environment)
|
|
implements MobilePushTarget {
|
|
public ApnsDeviceToken {
|
|
java.util.Objects.requireNonNull(environment, "environment");
|
|
if (value == null || value.isBlank()) throw new IllegalArgumentException("value");
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `./gradlew :modules:notification:notification-contact-api:test`
|
|
|
|
Expected: PASS: FID·legacy token·APNs token이 독립 타입이고 생명주기 enum이 존재한다.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add 'modules/notification/notification-contact-api/src/main/java/io/backend/skeleton/notification/contact/ContactPointValue.java' 'modules/notification/notification-contact-api/src/main/java/io/backend/skeleton/notification/contact/EmailAddress.java' 'modules/notification/notification-contact-api/src/main/java/io/backend/skeleton/notification/contact/PhoneNumber.java' 'modules/notification/notification-contact-api/src/main/java/io/backend/skeleton/notification/contact/MobilePushTarget.java' 'modules/notification/notification-contact-api/src/main/java/io/backend/skeleton/notification/contact/FcmInstallationId.java' 'modules/notification/notification-contact-api/src/main/java/io/backend/skeleton/notification/contact/LegacyFcmRegistrationToken.java' 'modules/notification/notification-contact-api/src/main/java/io/backend/skeleton/notification/contact/ApnsDeviceToken.java' 'modules/notification/notification-contact-api/src/main/java/io/backend/skeleton/notification/contact/ApnsEnvironment.java' 'modules/notification/notification-contact-api/src/main/java/io/backend/skeleton/notification/contact/ContactPointStatus.java' 'modules/notification/notification-contact-api/src/test/java/io/backend/skeleton/notification/contact/ContactPointTypeTest.java'
|
|
git commit -m "feat(notification): add protected contact point value types"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 7: Contact Point AES-GCM 보호와 HMAC lookup 구현
|
|
|
|
**Files:**
|
|
- Create: `modules/notification/notification-security/src/main/java/io/backend/skeleton/notification/security/SecretMaterialProvider.java`
|
|
- Create: `modules/notification/notification-security/src/main/java/io/backend/skeleton/notification/security/ContactPointProtector.java`
|
|
- Create: `modules/notification/notification-security/src/main/java/io/backend/skeleton/notification/security/AesGcmContactPointProtector.java`
|
|
- Create: `modules/notification/notification-security/src/main/java/io/backend/skeleton/notification/security/ProtectedContactPoint.java`
|
|
- Create: `modules/notification/notification-security/src/main/java/io/backend/skeleton/notification/security/SecretKeyMaterial.java`
|
|
- Create: `modules/notification/notification-security/src/main/java/io/backend/skeleton/notification/security/SecretPurpose.java`
|
|
- Create: `modules/notification/notification-security/src/main/java/io/backend/skeleton/notification/security/AccessContext.java`
|
|
- Test: `modules/notification/notification-security/src/test/java/io/backend/skeleton/notification/security/AesGcmContactPointProtectorTest.java`
|
|
|
|
**Interfaces:**
|
|
- Consumes: Task 6 ContactPointValue.
|
|
- Produces: `SecretMaterialProvider`, AES-256-GCM encrypted value, separate HMAC-SHA-256 fingerprint.
|
|
|
|
**Implementation requirements:**
|
|
- AES key는 정확히 256-bit인지 startup에서 검증한다.
|
|
- GCM nonce 재사용을 금지하고 SecureRandom으로 매번 생성한다.
|
|
- exception message에 plaintext를 포함하지 않는다.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```java
|
|
package io.backend.skeleton.notification.security;
|
|
|
|
import org.junit.jupiter.api.Test;
|
|
import io.backend.skeleton.notification.contact.EmailAddress;
|
|
import static org.assertj.core.api.Assertions.*;
|
|
|
|
class AesGcmContactPointProtectorTest {
|
|
@Test
|
|
void encryptsRoundTripAndProducesStableLookupFingerprint() {
|
|
var protector = SecurityFixture.protector();
|
|
var value = EmailAddress.parse("user@example.com");
|
|
var first = protector.protect(value);
|
|
var second = protector.protect(value);
|
|
|
|
assertThat(first.ciphertext()).isNotEqualTo(second.ciphertext());
|
|
assertThat(first.lookupHmac()).isEqualTo(second.lookupHmac());
|
|
assertThat(protector.reveal(first, SecurityFixture.access())).isEqualTo(value);
|
|
}
|
|
|
|
@Test
|
|
void encryptionAndHmacKeysAreDifferent() {
|
|
assertThatThrownBy(SecurityFixture::protectorWithSameKeys)
|
|
.isInstanceOf(IllegalArgumentException.class);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `./gradlew :modules:notification:notification-security:test --tests "*.AesGcmContactPointProtectorTest"`
|
|
|
|
Expected: FAIL: security protector classes가 없다.
|
|
|
|
- [ ] **Step 3: Write the minimal implementation**
|
|
|
|
Files to implement:
|
|
- `SecretMaterialProvider.java`
|
|
- `ContactPointProtector.java`
|
|
- `AesGcmContactPointProtector.java`
|
|
- `ProtectedContactPoint.java`
|
|
- `SecretKeyMaterial.java`
|
|
- `SecretPurpose.java`
|
|
- `AccessContext.java`
|
|
|
|
```java
|
|
public record ProtectedContactPoint(
|
|
String type,
|
|
String keyId,
|
|
byte[] nonce,
|
|
byte[] ciphertext,
|
|
String lookupHmac
|
|
) {
|
|
public ProtectedContactPoint {
|
|
nonce = nonce.clone();
|
|
ciphertext = ciphertext.clone();
|
|
}
|
|
}
|
|
|
|
public final class AesGcmContactPointProtector implements ContactPointProtector {
|
|
private static final String CIPHER = "AES/GCM/NoPadding";
|
|
private static final String HMAC = "HmacSHA256";
|
|
|
|
public ProtectedContactPoint protect(ContactPointValue value) {
|
|
var encryption = keys.activeKey(SecretPurpose.CONTACT_ENCRYPTION);
|
|
var lookup = keys.activeKey(SecretPurpose.CONTACT_LOOKUP_HMAC);
|
|
if (encryption.keyId().equals(lookup.keyId())) {
|
|
throw new IllegalArgumentException("encryption and HMAC keys must differ");
|
|
}
|
|
// SecureRandom 96-bit nonce, canonical type+value as AAD, 128-bit GCM tag.
|
|
return encryptAndFingerprint(value, encryption, lookup);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `./gradlew :modules:notification:notification-security:test`
|
|
|
|
Expected: PASS: 동일 값은 같은 HMAC lookup을 가지지만 nonce로 암호문이 달라지고 복호화된다.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add 'modules/notification/notification-security/src/main/java/io/backend/skeleton/notification/security/SecretMaterialProvider.java' 'modules/notification/notification-security/src/main/java/io/backend/skeleton/notification/security/ContactPointProtector.java' 'modules/notification/notification-security/src/main/java/io/backend/skeleton/notification/security/AesGcmContactPointProtector.java' 'modules/notification/notification-security/src/main/java/io/backend/skeleton/notification/security/ProtectedContactPoint.java' 'modules/notification/notification-security/src/main/java/io/backend/skeleton/notification/security/SecretKeyMaterial.java' 'modules/notification/notification-security/src/main/java/io/backend/skeleton/notification/security/SecretPurpose.java' 'modules/notification/notification-security/src/main/java/io/backend/skeleton/notification/security/AccessContext.java' 'modules/notification/notification-security/src/test/java/io/backend/skeleton/notification/security/AesGcmContactPointProtectorTest.java'
|
|
git commit -m "feat(notification): protect contact points at rest"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 8: Template Registry와 immutable version 구현
|
|
|
|
**Files:**
|
|
- Create: `modules/notification/notification-template-api/src/main/java/io/backend/skeleton/notification/template/NotificationTemplateVersion.java`
|
|
- Create: `modules/notification/notification-template-api/src/main/java/io/backend/skeleton/notification/template/TemplateRegistry.java`
|
|
- Create: `modules/notification/notification-template-api/src/main/java/io/backend/skeleton/notification/template/TemplateStatus.java`
|
|
- Create: `modules/notification/notification-template-api/src/main/java/io/backend/skeleton/notification/template/VariableSchema.java`
|
|
- Create: `modules/notification/notification-template-api/src/main/java/io/backend/skeleton/notification/template/TemplateContentDefinition.java`
|
|
- Create: `modules/notification/notification-template-api/src/main/java/io/backend/skeleton/notification/template/TemplateVersionConflictException.java`
|
|
- Test: `modules/notification/notification-template-api/src/test/java/io/backend/skeleton/notification/template/TemplateRegistryContractTest.java`
|
|
|
|
**Interfaces:**
|
|
- Consumes: Task 4 Channel과 TemplateSelection.
|
|
- Produces: Immutable `NotificationTemplateVersion`, explicit `TemplateSelection`, publish/disable registry contract.
|
|
|
|
**Implementation requirements:**
|
|
- Template ID/version/locale 조합을 unique하게 관리한다.
|
|
- disable은 기존 Notification의 retry/redrive용 조회를 삭제하지 않는다.
|
|
- Java class name을 template ID로 사용하지 않는다.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```java
|
|
package io.backend.skeleton.notification.template;
|
|
|
|
import org.junit.jupiter.api.Test;
|
|
import java.util.Locale;
|
|
import static org.assertj.core.api.Assertions.*;
|
|
|
|
class TemplateRegistryContractTest {
|
|
@Test
|
|
void publishedVersionCannotBeOverwritten() {
|
|
var registry = new InMemoryTemplateRegistry();
|
|
var template = TemplateFixture.version(1, Locale.KOREAN);
|
|
registry.publish(template);
|
|
assertThatThrownBy(() -> registry.publish(template.withChangedContent("changed")))
|
|
.isInstanceOf(TemplateVersionConflictException.class);
|
|
}
|
|
|
|
@Test
|
|
void selectionPinsExactVersion() {
|
|
var selection = new TemplateSelection("password-reset", 3, Locale.KOREAN);
|
|
assertThat(selection.version()).isEqualTo(3);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `./gradlew :modules:notification:notification-template-api:test --tests "*.TemplateRegistryContractTest"`
|
|
|
|
Expected: FAIL: template registry contract가 없다.
|
|
|
|
- [ ] **Step 3: Write the minimal implementation**
|
|
|
|
Files to implement:
|
|
- `NotificationTemplateVersion.java`
|
|
- `TemplateRegistry.java`
|
|
- `TemplateStatus.java`
|
|
- `VariableSchema.java`
|
|
- `TemplateContentDefinition.java`
|
|
- `TemplateVersionConflictException.java`
|
|
|
|
```java
|
|
public interface TemplateRegistry {
|
|
NotificationTemplateVersion get(TemplateSelection selection);
|
|
void publish(NotificationTemplateVersion version);
|
|
void disable(String templateId, long version);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `./gradlew :modules:notification:notification-template-api:test`
|
|
|
|
Expected: PASS: publish된 버전은 덮어쓰지 못하고 selection이 version을 고정한다.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add 'modules/notification/notification-template-api/src/main/java/io/backend/skeleton/notification/template/NotificationTemplateVersion.java' 'modules/notification/notification-template-api/src/main/java/io/backend/skeleton/notification/template/TemplateRegistry.java' 'modules/notification/notification-template-api/src/main/java/io/backend/skeleton/notification/template/TemplateStatus.java' 'modules/notification/notification-template-api/src/main/java/io/backend/skeleton/notification/template/VariableSchema.java' 'modules/notification/notification-template-api/src/main/java/io/backend/skeleton/notification/template/TemplateContentDefinition.java' 'modules/notification/notification-template-api/src/main/java/io/backend/skeleton/notification/template/TemplateVersionConflictException.java' 'modules/notification/notification-template-api/src/test/java/io/backend/skeleton/notification/template/TemplateRegistryContractTest.java'
|
|
git commit -m "feat(notification): add immutable template registry contract"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 9: JSON Schema 변수 검증과 Thymeleaf Reference Renderer 구현
|
|
|
|
**Files:**
|
|
- Create: `modules/notification/notification-template-api/src/main/java/io/backend/skeleton/notification/template/NotificationTemplateRenderer.java`
|
|
- Create: `modules/notification/notification-template-api/src/main/java/io/backend/skeleton/notification/template/RenderedNotificationContent.java`
|
|
- Create: `modules/notification/notification-template-api/src/main/java/io/backend/skeleton/notification/template/RenderCommand.java`
|
|
- Create: `modules/notification/notification-template-api/src/main/java/io/backend/skeleton/notification/template/TemplateVariableValidationException.java`
|
|
- Create: `modules/notification/notification-template-thymeleaf/src/main/java/io/backend/skeleton/notification/template/thymeleaf/JsonSchemaVariableValidator.java`
|
|
- Create: `modules/notification/notification-template-thymeleaf/src/main/java/io/backend/skeleton/notification/template/thymeleaf/ThymeleafNotificationRenderer.java`
|
|
- Test: `modules/notification/notification-template-thymeleaf/src/test/java/io/backend/skeleton/notification/template/thymeleaf/ThymeleafNotificationRendererTest.java`
|
|
|
|
**Interfaces:**
|
|
- Consumes: Task 3 content records, Task 8 template registry.
|
|
- Produces: `NotificationTemplateRenderer`, JSON Schema 2020-12 validation, deterministic rendered digest, locale fallback.
|
|
|
|
**Implementation requirements:**
|
|
- Renderer error message에 secret-classified variable value를 포함하지 않는다.
|
|
- Template resolver가 최신 version을 자동 선택하지 않고 exact selection을 사용한다.
|
|
- Thymeleaf type은 template-api에 노출하지 않는다.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```java
|
|
package io.backend.skeleton.notification.template.thymeleaf;
|
|
|
|
import org.junit.jupiter.api.Test;
|
|
import java.util.Locale;
|
|
import static org.assertj.core.api.Assertions.*;
|
|
|
|
class ThymeleafNotificationRendererTest {
|
|
@Test
|
|
void rejectsMissingRequiredVariableBeforeProviderCall() {
|
|
var renderer = RendererFixture.rendererWithRequiredVariable("code");
|
|
assertThatThrownBy(() -> renderer.render(RendererFixture.command(java.util.Map.of())))
|
|
.isInstanceOf(TemplateVariableValidationException.class);
|
|
}
|
|
|
|
@Test
|
|
void sameVersionAndVariablesProduceSameDigest() {
|
|
var renderer = RendererFixture.renderer();
|
|
var command = RendererFixture.command(java.util.Map.of("name", "동현"));
|
|
assertThat(renderer.render(command).contentDigest())
|
|
.isEqualTo(renderer.render(command).contentDigest());
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `./gradlew :modules:notification:notification-template-thymeleaf:test --tests "*.ThymeleafNotificationRendererTest"`
|
|
|
|
Expected: FAIL: renderer와 schema validator가 없다.
|
|
|
|
- [ ] **Step 3: Write the minimal implementation**
|
|
|
|
Files to implement:
|
|
- `NotificationTemplateRenderer.java`
|
|
- `RenderedNotificationContent.java`
|
|
- `RenderCommand.java`
|
|
- `TemplateVariableValidationException.java`
|
|
- `JsonSchemaVariableValidator.java`
|
|
- `ThymeleafNotificationRenderer.java`
|
|
|
|
```java
|
|
public interface NotificationTemplateRenderer {
|
|
Channel channel();
|
|
RenderedNotificationContent render(RenderCommand command);
|
|
}
|
|
|
|
public record RenderedNotificationContent(
|
|
NotificationContent content,
|
|
String contentDigest,
|
|
TemplateSelection templateSelection,
|
|
java.util.Locale resolvedLocale
|
|
) {}
|
|
|
|
// Renderer algorithm:
|
|
// 1. JSON Schema 2020-12 validation.
|
|
// 2. exact locale → language locale → template fallback → platform default.
|
|
// 3. deterministic canonical UTF-8 rendering.
|
|
// 4. SHA-256 digest over channel, template ID/version, locale and rendered fields.
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `./gradlew :modules:notification:notification-template-thymeleaf:test`
|
|
|
|
Expected: PASS: invalid variables are rejected and rendering digest is deterministic.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add 'modules/notification/notification-template-api/src/main/java/io/backend/skeleton/notification/template/NotificationTemplateRenderer.java' 'modules/notification/notification-template-api/src/main/java/io/backend/skeleton/notification/template/RenderedNotificationContent.java' 'modules/notification/notification-template-api/src/main/java/io/backend/skeleton/notification/template/RenderCommand.java' 'modules/notification/notification-template-api/src/main/java/io/backend/skeleton/notification/template/TemplateVariableValidationException.java' 'modules/notification/notification-template-thymeleaf/src/main/java/io/backend/skeleton/notification/template/thymeleaf/JsonSchemaVariableValidator.java' 'modules/notification/notification-template-thymeleaf/src/main/java/io/backend/skeleton/notification/template/thymeleaf/ThymeleafNotificationRenderer.java' 'modules/notification/notification-template-thymeleaf/src/test/java/io/backend/skeleton/notification/template/thymeleaf/ThymeleafNotificationRendererTest.java'
|
|
git commit -m "feat(notification): add template validation and rendering"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 10: Provider SPI, Capability, 실행 증거 모델 구현
|
|
|
|
**Files:**
|
|
- Create: `modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/NotificationProviderAdapter.java`
|
|
- Create: `modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/ProviderSubmission.java`
|
|
- Create: `modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/ProviderSubmissionResult.java`
|
|
- Create: `modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/ProviderCapabilities.java`
|
|
- Create: `modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/ProviderExecutionEvidence.java`
|
|
- Create: `modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/EvidenceCertainty.java`
|
|
- Create: `modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/ProviderProfileSnapshot.java`
|
|
- Create: `modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/ProviderFailure.java`
|
|
- Create: `modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/TraceContext.java`
|
|
- Create: `modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/EvidenceFact.java`
|
|
- Test: `modules/notification/notification-provider-spi/src/test/java/io/backend/skeleton/notification/provider/ProviderSpiContractTest.java`
|
|
|
|
**Interfaces:**
|
|
- Consumes: Tasks 2, 3, 6, 9의 ID·content·contact·rendered content.
|
|
- Produces: `NotificationProviderAdapter.submit`, capability model, `CONFIRMED/REJECTED/AMBIGUOUS` result와 execution evidence.
|
|
|
|
**Implementation requirements:**
|
|
- Provider native SDK object를 ProviderSubmission에 넣지 않는다.
|
|
- Adapter가 모르는 실행 사실은 `UNKNOWN` certainty로 기록한다.
|
|
- capability가 false인 기능을 runtime이 요청하면 startup 또는 dispatch 전에 거부한다.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```java
|
|
package io.backend.skeleton.notification.provider;
|
|
|
|
import org.junit.jupiter.api.Test;
|
|
import java.lang.reflect.Method;
|
|
import java.util.concurrent.CompletionStage;
|
|
import static org.assertj.core.api.Assertions.*;
|
|
|
|
class ProviderSpiContractTest {
|
|
@Test
|
|
void submitIsAsyncAndReturnsEvidenceResult() throws Exception {
|
|
Method method = NotificationProviderAdapter.class
|
|
.getMethod("submit", ProviderSubmission.class);
|
|
assertThat(method.getReturnType()).isEqualTo(CompletionStage.class);
|
|
}
|
|
|
|
@Test
|
|
void ambiguousResultDoesNotClaimAcceptance() {
|
|
var result = ProviderResultFixture.ambiguous();
|
|
assertThat(result.confirmation()).isEqualTo(AttemptConfirmation.AMBIGUOUS);
|
|
assertThat(result.evidenceLevel()).isNotEqualTo(EvidenceLevel.PROVIDER_ACCEPTED);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `./gradlew :modules:notification:notification-provider-spi:test --tests "*.ProviderSpiContractTest"`
|
|
|
|
Expected: FAIL: provider SPI가 없다.
|
|
|
|
- [ ] **Step 3: Write the minimal implementation**
|
|
|
|
Files to implement:
|
|
- `NotificationProviderAdapter.java`
|
|
- `ProviderSubmission.java`
|
|
- `ProviderSubmissionResult.java`
|
|
- `ProviderCapabilities.java`
|
|
- `ProviderExecutionEvidence.java`
|
|
- `EvidenceCertainty.java`
|
|
- `ProviderProfileSnapshot.java`
|
|
- `ProviderFailure.java`
|
|
- `TraceContext.java`
|
|
- `EvidenceFact.java`
|
|
|
|
```java
|
|
public interface NotificationProviderAdapter {
|
|
ProviderId providerId();
|
|
java.util.Set<Channel> channels();
|
|
ProviderCapabilities capabilities();
|
|
java.util.concurrent.CompletionStage<ProviderSubmissionResult> submit(
|
|
ProviderSubmission submission);
|
|
}
|
|
|
|
public record ProviderExecutionEvidence(
|
|
EvidenceFact requestStarted,
|
|
EvidenceFact requestBodyCommitted,
|
|
EvidenceFact responseReceived,
|
|
EvidenceFact providerAcceptance
|
|
) {}
|
|
|
|
public record EvidenceFact(boolean value, EvidenceCertainty certainty) {}
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `./gradlew :modules:notification:notification-provider-spi:test`
|
|
|
|
Expected: PASS: provider SPI가 CompletionStage와 명시적 evidence를 사용한다.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add 'modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/NotificationProviderAdapter.java' 'modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/ProviderSubmission.java' 'modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/ProviderSubmissionResult.java' 'modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/ProviderCapabilities.java' 'modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/ProviderExecutionEvidence.java' 'modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/EvidenceCertainty.java' 'modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/ProviderProfileSnapshot.java' 'modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/ProviderFailure.java' 'modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/TraceContext.java' 'modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/EvidenceFact.java' 'modules/notification/notification-provider-spi/src/test/java/io/backend/skeleton/notification/provider/ProviderSpiContractTest.java'
|
|
git commit -m "feat(notification): add provider adapter SPI and evidence contract"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 11: 안정 오류 계층과 FailureCategory 구현
|
|
|
|
**Files:**
|
|
- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/error/NotificationException.java`
|
|
- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/error/NotificationFailureDescriptor.java`
|
|
- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/error/FailureCategory.java`
|
|
- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/error/AmbiguousSubmissionException.java`
|
|
- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/error/IdempotencyConflictException.java`
|
|
- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/error/NotificationSuppressedException.java`
|
|
- Test: `modules/notification/notification-core-api/src/test/java/io/backend/skeleton/notification/api/error/NotificationExceptionTest.java`
|
|
|
|
**Interfaces:**
|
|
- Consumes: Task 2 evidence enum과 Task 4 Channel.
|
|
- Produces: Driver/SDK exception을 숨기는 안정 예외, low-cardinality failure descriptor.
|
|
|
|
**Implementation requirements:**
|
|
- Provider SDK exception을 public cause type으로 계약하지 않는다.
|
|
- cause는 내부 diagnostic에 보존할 수 있지만 message/body/address를 exception message에 합치지 않는다.
|
|
- failure code는 bounded registry로 관리한다.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```java
|
|
package io.backend.skeleton.notification.api.error;
|
|
|
|
import org.junit.jupiter.api.Test;
|
|
import static org.assertj.core.api.Assertions.*;
|
|
|
|
class NotificationExceptionTest {
|
|
@Test
|
|
void ambiguousDescriptorIsNonRetryableByDefault() {
|
|
var descriptor = NotificationFailureDescriptor.ambiguous(
|
|
"PROVIDER_RESPONSE_LOST", Channel.EMAIL, new ProviderId("ses"), 1);
|
|
assertThat(descriptor.ambiguous()).isTrue();
|
|
assertThat(descriptor.retryable()).isFalse();
|
|
}
|
|
|
|
@Test
|
|
void exceptionMessageDoesNotContainRecipient() {
|
|
var exception = new NotificationSuppressedException(
|
|
NotificationFailureDescriptor.suppressed("USER_OPT_OUT", Channel.SMS));
|
|
assertThat(exception.getMessage()).doesNotContain("+821012345678");
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `./gradlew :modules:notification:notification-core-api:test --tests "*.NotificationExceptionTest"`
|
|
|
|
Expected: FAIL: error hierarchy가 없다.
|
|
|
|
- [ ] **Step 3: Write the minimal implementation**
|
|
|
|
Files to implement:
|
|
- `NotificationException.java`
|
|
- `NotificationFailureDescriptor.java`
|
|
- `FailureCategory.java`
|
|
- `AmbiguousSubmissionException.java`
|
|
- `IdempotencyConflictException.java`
|
|
- `NotificationSuppressedException.java`
|
|
|
|
```java
|
|
public enum FailureCategory {
|
|
TRANSIENT_PROVIDER, THROTTLED, AUTHENTICATION, AUTHORIZATION,
|
|
INVALID_RECIPIENT, INVALID_PAYLOAD, TEMPLATE_FAILURE,
|
|
PERMANENT_PROVIDER, AMBIGUOUS_SUBMISSION,
|
|
CALLBACK_VALIDATION_FAILURE, CAPACITY_REJECTED, EXPIRED
|
|
}
|
|
|
|
public record NotificationFailureDescriptor(
|
|
String code,
|
|
FailureCategory category,
|
|
boolean retryable,
|
|
boolean ambiguous,
|
|
Channel channel,
|
|
ProviderId providerId,
|
|
int attemptNumber,
|
|
java.time.Duration elapsed
|
|
) {}
|
|
|
|
public abstract class NotificationException extends RuntimeException {
|
|
private final NotificationFailureDescriptor descriptor;
|
|
protected NotificationException(NotificationFailureDescriptor descriptor) {
|
|
super(descriptor.code());
|
|
this.descriptor = descriptor;
|
|
}
|
|
public NotificationFailureDescriptor descriptor() { return descriptor; }
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `./gradlew :modules:notification:notification-core-api:test`
|
|
|
|
Expected: PASS: ambiguity와 retryability가 descriptor에 분리되고 exception message가 sanitized된다.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/error/NotificationException.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/error/NotificationFailureDescriptor.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/error/FailureCategory.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/error/AmbiguousSubmissionException.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/error/IdempotencyConflictException.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/error/NotificationSuppressedException.java' 'modules/notification/notification-core-api/src/test/java/io/backend/skeleton/notification/api/error/NotificationExceptionTest.java'
|
|
git commit -m "feat(notification): add stable failure taxonomy"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 12: Flyway Notification 영속 스키마 구현
|
|
|
|
**Files:**
|
|
- Create: `modules/notification/notification-persistence-jpa/src/main/resources/db/migration/notification/V1__notification_core.sql`
|
|
- Create: `modules/notification/notification-persistence-jpa/src/main/resources/db/migration/notification/V2__notification_contact_template_policy.sql`
|
|
- Create: `modules/notification/notification-persistence-jpa/src/main/resources/db/migration/notification/V3__notification_inbox_admin.sql`
|
|
- Test: `modules/notification/notification-persistence-jpa/src/test/java/io/backend/skeleton/notification/persistence/NotificationFlywayMigrationTest.java`
|
|
|
|
**Interfaces:**
|
|
- Consumes: Tasks 2, 8, 11의 type names.
|
|
- Produces: PostgreSQL tables, unique constraints, SKIP LOCKED indexes, ProviderEvent dedup indexes.
|
|
|
|
**Implementation requirements:**
|
|
- enum은 PostgreSQL native enum이 아니라 varchar + application validation으로 시작한다.
|
|
- Contact Point ciphertext는 bytea, lookup HMAC은 fixed char로 저장한다.
|
|
- ProviderEvent raw payload에는 row-level size guard를 application과 DB check에 함께 둔다.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```java
|
|
package io.backend.skeleton.notification.persistence;
|
|
|
|
import org.junit.jupiter.api.Test;
|
|
import org.springframework.jdbc.core.JdbcTemplate;
|
|
import static org.assertj.core.api.Assertions.*;
|
|
|
|
class NotificationFlywayMigrationTest extends PostgreSqlNotificationTest {
|
|
@Test
|
|
void createsIdempotencyAndProviderEventUniqueness() {
|
|
JdbcTemplate jdbc = jdbc();
|
|
assertThat(indexNames(jdbc, "notification_request"))
|
|
.contains("uk_notification_request_idempotency");
|
|
assertThat(indexNames(jdbc, "notification_provider_event"))
|
|
.contains("uk_notification_provider_event_id", "uk_notification_provider_event_fingerprint");
|
|
}
|
|
|
|
@Test
|
|
void createsDispatchPartialIndex() {
|
|
assertThat(indexDefinition(jdbc(), "ix_notification_recipient_dispatch"))
|
|
.contains("next_dispatch_at")
|
|
.contains("READY_TO_DISPATCH")
|
|
.contains("RETRY_WAITING");
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `./gradlew :modules:notification:notification-persistence-jpa:test --tests "*.NotificationFlywayMigrationTest"`
|
|
|
|
Expected: FAIL: Flyway migration이 없어서 notification table과 index가 존재하지 않는다.
|
|
|
|
- [ ] **Step 3: Write the minimal implementation**
|
|
|
|
Files to implement:
|
|
- `V1__notification_core.sql`
|
|
- `V2__notification_contact_template_policy.sql`
|
|
- `V3__notification_inbox_admin.sql`
|
|
|
|
```java
|
|
CREATE TABLE notification_request (
|
|
id uuid PRIMARY KEY,
|
|
tenant_id varchar(100) NOT NULL,
|
|
idempotency_key varchar(200) NOT NULL,
|
|
request_fingerprint char(64) NOT NULL,
|
|
category varchar(120) NOT NULL,
|
|
template_id varchar(160) NOT NULL,
|
|
template_version bigint NOT NULL,
|
|
strategy_type varchar(40) NOT NULL,
|
|
schedule_at timestamptz,
|
|
not_before timestamptz,
|
|
expires_at timestamptz,
|
|
request_status varchar(40) NOT NULL,
|
|
correlation_id varchar(160),
|
|
metadata_json jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
created_at timestamptz NOT NULL,
|
|
updated_at timestamptz NOT NULL,
|
|
version bigint NOT NULL DEFAULT 0,
|
|
CONSTRAINT uk_notification_request_idempotency
|
|
UNIQUE (tenant_id, idempotency_key)
|
|
);
|
|
|
|
CREATE TABLE notification_recipient_delivery (
|
|
id uuid PRIMARY KEY,
|
|
notification_id uuid NOT NULL REFERENCES notification_request(id),
|
|
recipient_ref varchar(200) NOT NULL,
|
|
routing_plan_json jsonb NOT NULL,
|
|
route_cursor integer NOT NULL DEFAULT 0,
|
|
delivery_state varchar(40) NOT NULL,
|
|
submission_outcome varchar(40) NOT NULL,
|
|
delivery_outcome varchar(40) NOT NULL,
|
|
evidence_level varchar(50) NOT NULL,
|
|
ambiguous_attempt_exists boolean NOT NULL DEFAULT false,
|
|
duplicate_risk boolean NOT NULL DEFAULT false,
|
|
next_dispatch_at timestamptz,
|
|
lease_owner varchar(120),
|
|
lease_until timestamptz,
|
|
attempt_count integer NOT NULL DEFAULT 0,
|
|
created_at timestamptz NOT NULL,
|
|
updated_at timestamptz NOT NULL,
|
|
version bigint NOT NULL DEFAULT 0
|
|
);
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `./gradlew :modules:notification:notification-persistence-jpa:test`
|
|
|
|
Expected: PASS: PostgreSQL 16에서 모든 migration과 index assertion이 통과한다.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add 'modules/notification/notification-persistence-jpa/src/main/resources/db/migration/notification/V1__notification_core.sql' 'modules/notification/notification-persistence-jpa/src/main/resources/db/migration/notification/V2__notification_contact_template_policy.sql' 'modules/notification/notification-persistence-jpa/src/main/resources/db/migration/notification/V3__notification_inbox_admin.sql' 'modules/notification/notification-persistence-jpa/src/test/java/io/backend/skeleton/notification/persistence/NotificationFlywayMigrationTest.java'
|
|
git commit -m "feat(notification): add durable notification schema"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 13: JPA Entity, Repository, Tenant Guard 구현
|
|
|
|
**Files:**
|
|
- Create: `modules/notification/notification-persistence-jpa/src/main/java/io/backend/skeleton/notification/persistence/NotificationRequestEntity.java`
|
|
- Create: `modules/notification/notification-persistence-jpa/src/main/java/io/backend/skeleton/notification/persistence/RecipientDeliveryEntity.java`
|
|
- Create: `modules/notification/notification-persistence-jpa/src/main/java/io/backend/skeleton/notification/persistence/DeliveryAttemptEntity.java`
|
|
- Create: `modules/notification/notification-persistence-jpa/src/main/java/io/backend/skeleton/notification/persistence/ProviderEventEntity.java`
|
|
- Create: `modules/notification/notification-persistence-jpa/src/main/java/io/backend/skeleton/notification/persistence/NotificationRequestRepository.java`
|
|
- Create: `modules/notification/notification-persistence-jpa/src/main/java/io/backend/skeleton/notification/persistence/RecipientDeliveryRepository.java`
|
|
- Create: `modules/notification/notification-persistence-jpa/src/main/java/io/backend/skeleton/notification/persistence/TenantBoundRepositoryGuard.java`
|
|
- Test: `modules/notification/notification-persistence-jpa/src/test/java/io/backend/skeleton/notification/persistence/NotificationRepositoryTest.java`
|
|
|
|
**Interfaces:**
|
|
- Consumes: Task 12 schema, Tasks 2·13 state names.
|
|
- Produces: Optimistic-lock JPA entities and tenant-scoped repositories.
|
|
|
|
**Implementation requirements:**
|
|
- Entity setter를 public으로 열지 않고 domain transition method를 사용한다.
|
|
- repository가 tenant 없는 contact/request lookup을 일반 API로 제공하지 않는다.
|
|
- JPA entity를 Core public API에서 반환하지 않는다.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```java
|
|
package io.backend.skeleton.notification.persistence;
|
|
|
|
import org.junit.jupiter.api.Test;
|
|
import static org.assertj.core.api.Assertions.*;
|
|
|
|
class NotificationRepositoryTest extends PostgreSqlNotificationTest {
|
|
@Test
|
|
void requestLookupRequiresTenant() {
|
|
var saved = fixture().request("tenant-a", "idem-1");
|
|
repository().save(saved);
|
|
assertThat(repository().findByTenantAndIdempotencyKey("tenant-b", "idem-1"))
|
|
.isEmpty();
|
|
}
|
|
|
|
@Test
|
|
void optimisticVersionRejectsConcurrentProjectionUpdate() {
|
|
var delivery = fixture().recipient();
|
|
recipientRepository().saveAndFlush(delivery);
|
|
var first = recipientRepository().findById(delivery.id()).orElseThrow();
|
|
var second = recipientRepository().findById(delivery.id()).orElseThrow();
|
|
first.markSuppressed();
|
|
recipientRepository().saveAndFlush(first);
|
|
second.markDispatching();
|
|
assertThatThrownBy(() -> recipientRepository().saveAndFlush(second))
|
|
.isInstanceOf(org.springframework.orm.ObjectOptimisticLockingFailureException.class);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `./gradlew :modules:notification:notification-persistence-jpa:test --tests "*.NotificationRepositoryTest"`
|
|
|
|
Expected: FAIL: JPA entity와 repository가 없다.
|
|
|
|
- [ ] **Step 3: Write the minimal implementation**
|
|
|
|
Files to implement:
|
|
- `NotificationRequestEntity.java`
|
|
- `RecipientDeliveryEntity.java`
|
|
- `DeliveryAttemptEntity.java`
|
|
- `ProviderEventEntity.java`
|
|
- `NotificationRequestRepository.java`
|
|
- `RecipientDeliveryRepository.java`
|
|
- `TenantBoundRepositoryGuard.java`
|
|
|
|
```java
|
|
@Entity
|
|
@Table(name = "notification_request")
|
|
public class NotificationRequestEntity {
|
|
@Id private UUID id;
|
|
@Column(name = "tenant_id", nullable = false) private String tenantId;
|
|
@Column(name = "idempotency_key", nullable = false) private String idempotencyKey;
|
|
@Column(name = "request_fingerprint", nullable = false) private String requestFingerprint;
|
|
@Version private long version;
|
|
// package-private no-arg constructor; static factory enforces invariants.
|
|
}
|
|
|
|
public interface NotificationRequestRepository
|
|
extends JpaRepository<NotificationRequestEntity, UUID> {
|
|
Optional<NotificationRequestEntity> findByTenantIdAndIdempotencyKey(
|
|
String tenantId, String idempotencyKey);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `./gradlew :modules:notification:notification-persistence-jpa:test`
|
|
|
|
Expected: PASS: tenant-scoped lookup과 optimistic lock 테스트가 통과한다.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add 'modules/notification/notification-persistence-jpa/src/main/java/io/backend/skeleton/notification/persistence/NotificationRequestEntity.java' 'modules/notification/notification-persistence-jpa/src/main/java/io/backend/skeleton/notification/persistence/RecipientDeliveryEntity.java' 'modules/notification/notification-persistence-jpa/src/main/java/io/backend/skeleton/notification/persistence/DeliveryAttemptEntity.java' 'modules/notification/notification-persistence-jpa/src/main/java/io/backend/skeleton/notification/persistence/ProviderEventEntity.java' 'modules/notification/notification-persistence-jpa/src/main/java/io/backend/skeleton/notification/persistence/NotificationRequestRepository.java' 'modules/notification/notification-persistence-jpa/src/main/java/io/backend/skeleton/notification/persistence/RecipientDeliveryRepository.java' 'modules/notification/notification-persistence-jpa/src/main/java/io/backend/skeleton/notification/persistence/TenantBoundRepositoryGuard.java' 'modules/notification/notification-persistence-jpa/src/test/java/io/backend/skeleton/notification/persistence/NotificationRepositoryTest.java'
|
|
git commit -m "feat(notification): add notification JPA repositories"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 14: ProviderEvent 원장과 Channel Projector 기반 구현
|
|
|
|
**Files:**
|
|
- Create: `modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/ProviderEventRecord.java`
|
|
- Create: `modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/ProviderEventProjector.java`
|
|
- Create: `modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/DeliveryProjection.java`
|
|
- Create: `modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/ProjectionResult.java`
|
|
- Create: `modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/DeliveryAttemptSnapshot.java`
|
|
- Create: `modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/EngagementFacts.java`
|
|
- Create: `modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/SuppressionFacts.java`
|
|
- Create: `modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/ProviderEventLedger.java`
|
|
- Create: `modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/VerifiedProviderEvent.java`
|
|
- Create: `modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/AppendEventResult.java`
|
|
- Create: `modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/ProviderEventSource.java`
|
|
- Create: `modules/notification/notification-persistence-jpa/src/main/java/io/backend/skeleton/notification/persistence/JpaProviderEventLedger.java`
|
|
- Test: `modules/notification/notification-callback-api/src/test/java/io/backend/skeleton/notification/callback/ProjectionMergeContractTest.java`
|
|
|
|
**Interfaces:**
|
|
- Consumes: Tasks 2 evidence, Task 13 persistence repositories.
|
|
- Produces: Append-only provider event ledger, idempotent projector, out-of-order merge contract.
|
|
|
|
**Implementation requirements:**
|
|
- single numeric status priority를 사용하지 않는다.
|
|
- event raw payload를 수정하지 않는다.
|
|
- projector 재실행은 동일 projection을 생성해야 한다.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```java
|
|
package io.backend.skeleton.notification.callback;
|
|
|
|
import org.junit.jupiter.api.Test;
|
|
import static org.assertj.core.api.Assertions.*;
|
|
|
|
class ProjectionMergeContractTest {
|
|
@Test
|
|
void sentAfterDeliveredDoesNotDowngrade() {
|
|
var projector = CallbackFixture.twilioProjector();
|
|
var delivered = projector.project(
|
|
CallbackFixture.attempt(), CallbackFixture.event("delivered"),
|
|
DeliveryProjection.empty());
|
|
var lateSent = projector.project(
|
|
CallbackFixture.attempt(), CallbackFixture.event("sent"),
|
|
delivered.projection());
|
|
assertThat(lateSent.projection().deliveryOutcome())
|
|
.isEqualTo(DeliveryOutcome.DELIVERED);
|
|
}
|
|
|
|
@Test
|
|
void complaintAddsFactWithoutRemovingDelivery() {
|
|
var result = CallbackFixture.emailProjector().project(
|
|
CallbackFixture.attempt(), CallbackFixture.event("complaint"),
|
|
CallbackFixture.deliveredProjection());
|
|
assertThat(result.projection().deliveryOutcome()).isEqualTo(DeliveryOutcome.DELIVERED);
|
|
assertThat(result.projection().suppressionFacts().complained()).isTrue();
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `./gradlew :modules:notification:notification-callback-api:test --tests "*.ProjectionMergeContractTest"`
|
|
|
|
Expected: FAIL: event ledger와 projector contract가 없다.
|
|
|
|
- [ ] **Step 3: Write the minimal implementation**
|
|
|
|
Files to implement:
|
|
- `ProviderEventRecord.java`
|
|
- `ProviderEventProjector.java`
|
|
- `DeliveryProjection.java`
|
|
- `ProjectionResult.java`
|
|
- `DeliveryAttemptSnapshot.java`
|
|
- `EngagementFacts.java`
|
|
- `SuppressionFacts.java`
|
|
- `ProviderEventLedger.java`
|
|
- `VerifiedProviderEvent.java`
|
|
- `AppendEventResult.java`
|
|
- `ProviderEventSource.java`
|
|
- `JpaProviderEventLedger.java`
|
|
|
|
```java
|
|
public interface ProviderEventProjector {
|
|
ProviderId providerId();
|
|
ProjectionResult project(
|
|
DeliveryAttemptSnapshot attempt,
|
|
ProviderEventRecord event,
|
|
DeliveryProjection current);
|
|
}
|
|
|
|
public record DeliveryProjection(
|
|
SubmissionOutcome submissionOutcome,
|
|
DeliveryOutcome deliveryOutcome,
|
|
EvidenceLevel evidenceLevel,
|
|
EngagementFacts engagementFacts,
|
|
SuppressionFacts suppressionFacts
|
|
) {}
|
|
|
|
public interface ProviderEventLedger {
|
|
AppendEventResult append(VerifiedProviderEvent event);
|
|
java.util.List<ProviderEventRecord> pendingProjection(int limit);
|
|
void markApplied(ProviderEventId eventId, ProjectionResult result);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `./gradlew :modules:notification:notification-callback-api:test :modules:notification:notification-persistence-jpa:test`
|
|
|
|
Expected: PASS: 역순 event가 downgrade되지 않고 event append가 idempotent하다.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add 'modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/ProviderEventRecord.java' 'modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/ProviderEventProjector.java' 'modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/DeliveryProjection.java' 'modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/ProjectionResult.java' 'modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/DeliveryAttemptSnapshot.java' 'modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/EngagementFacts.java' 'modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/SuppressionFacts.java' 'modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/ProviderEventLedger.java' 'modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/VerifiedProviderEvent.java' 'modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/AppendEventResult.java' 'modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/ProviderEventSource.java' 'modules/notification/notification-persistence-jpa/src/main/java/io/backend/skeleton/notification/persistence/JpaProviderEventLedger.java' 'modules/notification/notification-callback-api/src/test/java/io/backend/skeleton/notification/callback/ProjectionMergeContractTest.java'
|
|
git commit -m "feat(notification): add provider event ledger and projectors"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 15: Idempotent Submit Application Service 구현
|
|
|
|
**Files:**
|
|
- Create: `modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/NotificationSubmissionService.java`
|
|
- Create: `modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/RequestFingerprint.java`
|
|
- Create: `modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/CanonicalNotificationPlanWriter.java`
|
|
- Test: `modules/notification/notification-dispatch-runtime/src/test/java/io/backend/skeleton/notification/dispatch/NotificationSubmissionServiceTest.java`
|
|
|
|
**Interfaces:**
|
|
- Consumes: Tasks 4·5 plan/API, Task 8 exact template, Task 13 repositories.
|
|
- Produces: Transactional submit, canonical fingerprint, unique conflict convergence, durable receipt.
|
|
|
|
**Implementation requirements:**
|
|
- fingerprint에 recipient, template version, variables digest, schedule/expiry, routing을 포함한다.
|
|
- Provider 호출을 submit transaction에 포함하지 않는다.
|
|
- Receipt acceptedAt은 DB에 저장한 createdAt과 동일하다.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```java
|
|
package io.backend.skeleton.notification.dispatch;
|
|
|
|
import org.junit.jupiter.api.Test;
|
|
import java.util.concurrent.*;
|
|
import static org.assertj.core.api.Assertions.*;
|
|
|
|
class NotificationSubmissionServiceTest extends PostgreSqlNotificationTest {
|
|
@Test
|
|
void concurrentSameRequestReturnsOneNotificationId() throws Exception {
|
|
var service = service();
|
|
var plan = fixture().plan("idem-1");
|
|
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
|
|
var a = executor.submit(() -> service.submit(plan));
|
|
var b = executor.submit(() -> service.submit(plan));
|
|
assertThat(a.get().notificationId()).isEqualTo(b.get().notificationId());
|
|
}
|
|
assertThat(requestCount()).isEqualTo(1);
|
|
}
|
|
|
|
@Test
|
|
void sameKeyDifferentFingerprintFails() {
|
|
service().submit(fixture().plan("idem-2"));
|
|
assertThatThrownBy(() -> service().submit(fixture().changedPlan("idem-2")))
|
|
.isInstanceOf(IdempotencyConflictException.class);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `./gradlew :modules:notification:notification-dispatch-runtime:test --tests "*.NotificationSubmissionServiceTest"`
|
|
|
|
Expected: FAIL: submission service가 없다.
|
|
|
|
- [ ] **Step 3: Write the minimal implementation**
|
|
|
|
Files to implement:
|
|
- `NotificationSubmissionService.java`
|
|
- `RequestFingerprint.java`
|
|
- `CanonicalNotificationPlanWriter.java`
|
|
|
|
```java
|
|
@Transactional
|
|
public NotificationReceipt submit(NotificationPlan plan) {
|
|
var fingerprint = fingerprint.of(plan);
|
|
var existing = requests.findByTenantIdAndIdempotencyKey(
|
|
plan.tenantId().value(), plan.idempotencyKey().value());
|
|
if (existing.isPresent()) return compareAndReturn(existing.get(), fingerprint);
|
|
|
|
try {
|
|
var aggregate = factory.create(plan, fingerprint, clock.instant());
|
|
requests.saveAndFlush(aggregate.request());
|
|
recipients.saveAll(aggregate.recipients());
|
|
return aggregate.receipt();
|
|
} catch (DataIntegrityViolationException duplicate) {
|
|
var winner = requests.findByTenantIdAndIdempotencyKey(
|
|
plan.tenantId().value(), plan.idempotencyKey().value()).orElseThrow();
|
|
return compareAndReturn(winner, fingerprint);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `./gradlew :modules:notification:notification-dispatch-runtime:test`
|
|
|
|
Expected: PASS: 동시 submit이 하나로 수렴하고 fingerprint conflict가 거부된다.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add 'modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/NotificationSubmissionService.java' 'modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/RequestFingerprint.java' 'modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/CanonicalNotificationPlanWriter.java' 'modules/notification/notification-dispatch-runtime/src/test/java/io/backend/skeleton/notification/dispatch/NotificationSubmissionServiceTest.java'
|
|
git commit -m "feat(notification): add idempotent durable submission"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 16: PostgreSQL Durable Scheduler와 Lease Claim 구현
|
|
|
|
**Files:**
|
|
- Create: `modules/notification/notification-persistence-jpa/src/main/java/io/backend/skeleton/notification/persistence/RecipientLeaseRepository.java`
|
|
- Create: `modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/NotificationScheduler.java`
|
|
- Create: `modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/RecipientLease.java`
|
|
- Create: `modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/LeaseRecoveryService.java`
|
|
- Test: `modules/notification/notification-dispatch-runtime/src/test/java/io/backend/skeleton/notification/dispatch/NotificationSchedulerConcurrencyTest.java`
|
|
|
|
**Interfaces:**
|
|
- Consumes: Task 12 dispatch index, Task 13 repositories, Task 15 recipient rows.
|
|
- Produces: SKIP LOCKED claim, lease expiry, restart recovery, bounded batch scheduler.
|
|
|
|
**Implementation requirements:**
|
|
- lease duration, batch size, poll interval은 bounded validated property다.
|
|
- scheduler thread가 Provider 호출을 직접 수행하지 않고 dispatcher queue에 전달한다.
|
|
- shutdown 시작 후 새 lease를 획득하지 않는다.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```java
|
|
package io.backend.skeleton.notification.dispatch;
|
|
|
|
import org.junit.jupiter.api.Test;
|
|
import java.time.Duration;
|
|
import java.util.concurrent.*;
|
|
import static org.assertj.core.api.Assertions.*;
|
|
|
|
class NotificationSchedulerConcurrencyTest extends PostgreSqlNotificationTest {
|
|
@Test
|
|
void twoWorkersNeverClaimSameRecipient() throws Exception {
|
|
insertReadyRecipients(100);
|
|
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
|
|
var first = executor.submit(() -> scheduler("worker-a").claim(60));
|
|
var second = executor.submit(() -> scheduler("worker-b").claim(60));
|
|
var all = new java.util.HashSet<>(first.get());
|
|
assertThat(all.addAll(second.get())).isTrue();
|
|
assertThat(all).hasSize(100);
|
|
}
|
|
}
|
|
|
|
@Test
|
|
void expiredLeaseIsRecovered() {
|
|
var id = insertExpiredLeasedRecipient();
|
|
assertThat(scheduler("worker-b").claim(1)).containsExactly(id);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `./gradlew :modules:notification:notification-dispatch-runtime:test --tests "*.NotificationSchedulerConcurrencyTest"`
|
|
|
|
Expected: FAIL: scheduler와 lease SQL이 없다.
|
|
|
|
- [ ] **Step 3: Write the minimal implementation**
|
|
|
|
Files to implement:
|
|
- `RecipientLeaseRepository.java`
|
|
- `NotificationScheduler.java`
|
|
- `RecipientLease.java`
|
|
- `LeaseRecoveryService.java`
|
|
|
|
```java
|
|
// RecipientLeaseRepository native query
|
|
SELECT id
|
|
FROM notification_recipient_delivery
|
|
WHERE next_dispatch_at <= :now
|
|
AND delivery_state IN ('READY_TO_DISPATCH', 'RETRY_WAITING')
|
|
AND (lease_until IS NULL OR lease_until < :now)
|
|
ORDER BY next_dispatch_at, id
|
|
FOR UPDATE SKIP LOCKED
|
|
LIMIT :limit
|
|
|
|
@Transactional
|
|
public java.util.List<RecipientDeliveryId> claim(String workerId, int limit) {
|
|
var ids = repository.selectClaimable(clock.instant(), limit);
|
|
repository.markLeased(ids, workerId, clock.instant().plus(leaseDuration));
|
|
return ids.stream().map(RecipientDeliveryId::new).toList();
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `./gradlew :modules:notification:notification-dispatch-runtime:test`
|
|
|
|
Expected: PASS: worker 간 중복 claim이 없고 만료 lease가 복구된다.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add 'modules/notification/notification-persistence-jpa/src/main/java/io/backend/skeleton/notification/persistence/RecipientLeaseRepository.java' 'modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/NotificationScheduler.java' 'modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/RecipientLease.java' 'modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/LeaseRecoveryService.java' 'modules/notification/notification-dispatch-runtime/src/test/java/io/backend/skeleton/notification/dispatch/NotificationSchedulerConcurrencyTest.java'
|
|
git commit -m "feat(notification): add durable scheduler and lease recovery"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 17: Suppression·Preference·Consent Primitive 구현
|
|
|
|
**Files:**
|
|
- Create: `modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/SuppressionEntry.java`
|
|
- Create: `modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/SuppressionReason.java`
|
|
- Create: `modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/NotificationEligibilityPolicy.java`
|
|
- Create: `modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/PreferenceRecord.java`
|
|
- Create: `modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/ConsentRecord.java`
|
|
- Create: `modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/SuppressionId.java`
|
|
- Create: `modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/SuppressionScope.java`
|
|
- Create: `modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/SuppressionSource.java`
|
|
- Create: `modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/EligibilityResult.java`
|
|
- Create: `modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/NotificationContext.java`
|
|
- Create: `modules/notification/notification-persistence-jpa/src/main/java/io/backend/skeleton/notification/persistence/JpaSuppressionRepository.java`
|
|
- Test: `modules/notification/notification-policy/src/test/java/io/backend/skeleton/notification/policy/NotificationEligibilityTest.java`
|
|
|
|
**Interfaces:**
|
|
- Consumes: Task 6 Contact Point, Task 12 policy tables.
|
|
- Produces: Distinct suppression/preference/consent models and final dispatch eligibility evaluation.
|
|
|
|
**Implementation requirements:**
|
|
- 법률·광고 분류를 Core enum으로 만들지 않는다.
|
|
- submit와 dispatch 직전 모두 평가하되 dispatch 결과를 최종으로 사용한다.
|
|
- suppression 변경은 audit 대상이다.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```java
|
|
package io.backend.skeleton.notification.policy;
|
|
|
|
import org.junit.jupiter.api.Test;
|
|
import java.time.Instant;
|
|
import static org.assertj.core.api.Assertions.*;
|
|
|
|
class NotificationEligibilityTest {
|
|
@Test
|
|
void mandatorySuppressionOverridesPreference() {
|
|
var engine = PolicyFixture.engine(
|
|
PolicyFixture.preference(Channel.EMAIL),
|
|
PolicyFixture.suppression(SuppressionReason.COMPLAINT));
|
|
assertThat(engine.evaluate(PolicyFixture.context()).allowed()).isFalse();
|
|
}
|
|
|
|
@Test
|
|
void expiredTemporarySuppressionDoesNotBlock() {
|
|
var suppression = PolicyFixture.temporarySuppression(
|
|
Instant.parse("2026-08-09T00:00:00Z"));
|
|
assertThat(PolicyFixture.engine(suppression)
|
|
.evaluate(PolicyFixture.contextAt("2026-08-10T00:00:00Z")).allowed()).isTrue();
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `./gradlew :modules:notification:notification-policy:test --tests "*.NotificationEligibilityTest"`
|
|
|
|
Expected: FAIL: policy primitive가 없다.
|
|
|
|
- [ ] **Step 3: Write the minimal implementation**
|
|
|
|
Files to implement:
|
|
- `SuppressionEntry.java`
|
|
- `SuppressionReason.java`
|
|
- `NotificationEligibilityPolicy.java`
|
|
- `PreferenceRecord.java`
|
|
- `ConsentRecord.java`
|
|
- `SuppressionId.java`
|
|
- `SuppressionScope.java`
|
|
- `SuppressionSource.java`
|
|
- `EligibilityResult.java`
|
|
- `NotificationContext.java`
|
|
- `JpaSuppressionRepository.java`
|
|
|
|
```java
|
|
public interface NotificationEligibilityPolicy {
|
|
EligibilityResult evaluate(NotificationContext context);
|
|
}
|
|
|
|
public enum SuppressionReason {
|
|
USER_OPT_OUT, HARD_BOUNCE, COMPLAINT, INVALID_TOKEN, INVALID_PHONE,
|
|
ADMIN_BLOCK, PROVIDER_BLOCK, TEMPORARY_SUPPRESSION
|
|
}
|
|
|
|
public record EligibilityResult(
|
|
boolean allowed,
|
|
java.util.List<String> reasonCodes
|
|
) {}
|
|
|
|
// Composite order: mandatory internal suppression → provider suppression
|
|
// → injected application eligibility. Preference selects channel only after eligibility.
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `./gradlew :modules:notification:notification-policy:test :modules:notification:notification-persistence-jpa:test`
|
|
|
|
Expected: PASS: suppression이 preference보다 우선하고 expiry가 적용된다.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add 'modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/SuppressionEntry.java' 'modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/SuppressionReason.java' 'modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/NotificationEligibilityPolicy.java' 'modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/PreferenceRecord.java' 'modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/ConsentRecord.java' 'modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/SuppressionId.java' 'modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/SuppressionScope.java' 'modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/SuppressionSource.java' 'modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/EligibilityResult.java' 'modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/NotificationContext.java' 'modules/notification/notification-persistence-jpa/src/main/java/io/backend/skeleton/notification/persistence/JpaSuppressionRepository.java' 'modules/notification/notification-policy/src/test/java/io/backend/skeleton/notification/policy/NotificationEligibilityTest.java'
|
|
git commit -m "feat(notification): add suppression preference and consent primitives"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 18: Routing·Ordered Fallback Decision Engine 구현
|
|
|
|
**Files:**
|
|
- Create: `modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/RoutingDecisionEngine.java`
|
|
- Create: `modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/RoutingDecision.java`
|
|
- Create: `modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/RouteCandidate.java`
|
|
- Create: `modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/RoutingContext.java`
|
|
- Test: `modules/notification/notification-policy/src/test/java/io/backend/skeleton/notification/policy/RoutingDecisionEngineTest.java`
|
|
|
|
**Interfaces:**
|
|
- Consumes: Task 4 strategies, Task 11 failure category, Task 17 eligibility.
|
|
- Produces: Explicit/ordered routing, invalid-recipient fallback, ambiguous fallback hard block.
|
|
|
|
**Implementation requirements:**
|
|
- Provider accepted 이후 fallback은 기본 금지한다.
|
|
- route candidate는 active Contact Point와 enabled Provider Profile을 모두 요구한다.
|
|
- Parallel first-success를 Stable engine에 넣지 않는다.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```java
|
|
package io.backend.skeleton.notification.policy;
|
|
|
|
import org.junit.jupiter.api.Test;
|
|
import static org.assertj.core.api.Assertions.*;
|
|
|
|
class RoutingDecisionEngineTest {
|
|
@Test
|
|
void ambiguousAttemptBlocksAutomaticFallback() {
|
|
var decision = engine().next(PolicyFixture.ambiguousPushThenSms());
|
|
assertThat(decision.fallbackAllowed()).isFalse();
|
|
assertThat(decision.reconciliationRequired()).isTrue();
|
|
assertThat(decision.duplicateRisk()).isTrue();
|
|
}
|
|
|
|
@Test
|
|
void invalidPushTargetFallsBackToSms() {
|
|
var decision = engine().next(PolicyFixture.invalidPushThenSms());
|
|
assertThat(decision.selected().orElseThrow().channel()).isEqualTo(Channel.SMS);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `./gradlew :modules:notification:notification-policy:test --tests "*.RoutingDecisionEngineTest"`
|
|
|
|
Expected: FAIL: routing decision engine이 없다.
|
|
|
|
- [ ] **Step 3: Write the minimal implementation**
|
|
|
|
Files to implement:
|
|
- `RoutingDecisionEngine.java`
|
|
- `RoutingDecision.java`
|
|
- `RouteCandidate.java`
|
|
- `RoutingContext.java`
|
|
|
|
```java
|
|
public RoutingDecision next(RoutingContext context) {
|
|
if (context.ambiguousAttemptExists()) {
|
|
return RoutingDecision.reconcile("AMBIGUOUS_ATTEMPT", true);
|
|
}
|
|
if (context.lastFailure() == FailureCategory.INVALID_RECIPIENT) {
|
|
return selectNextEligibleRoute(context);
|
|
}
|
|
if (context.lastSubmission() == SubmissionOutcome.CONFIRMED_ACCEPTED) {
|
|
return RoutingDecision.stop("PROVIDER_ALREADY_ACCEPTED");
|
|
}
|
|
return selectCurrentOrNextRoute(context);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `./gradlew :modules:notification:notification-policy:test`
|
|
|
|
Expected: PASS: ambiguous fallback이 차단되고 invalid recipient만 안전하게 다음 채널로 이동한다.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add 'modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/RoutingDecisionEngine.java' 'modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/RoutingDecision.java' 'modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/RouteCandidate.java' 'modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/RoutingContext.java' 'modules/notification/notification-policy/src/test/java/io/backend/skeleton/notification/policy/RoutingDecisionEngineTest.java'
|
|
git commit -m "feat(notification): add safe routing and fallback engine"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 19: Retry Decision Engine과 Expiry·Budget Guard 구현
|
|
|
|
**Files:**
|
|
- Create: `modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/RetryContext.java`
|
|
- Create: `modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/RetryDecision.java`
|
|
- Create: `modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/NotificationRetryPolicy.java`
|
|
- Create: `modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/RetryBudget.java`
|
|
- Test: `modules/notification/notification-policy/src/test/java/io/backend/skeleton/notification/policy/NotificationRetryPolicyTest.java`
|
|
|
|
**Interfaces:**
|
|
- Consumes: Tasks 10·11 provider evidence/error, Task 18 routing.
|
|
- Produces: RetryAfter/Reconcile/Fallback/Stop decision, jittered backoff, expiry and budget enforcement.
|
|
|
|
**Implementation requirements:**
|
|
- Retry budget은 provider profile별 원 요청 대비 추가 시도를 제한한다.
|
|
- Retry-After는 maxBackoff와 expiresAt을 초과하지 않는 범위에서 존중한다.
|
|
- jitter가 deterministic test Clock/RandomSource로 주입 가능해야 한다.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```java
|
|
package io.backend.skeleton.notification.policy;
|
|
|
|
import org.junit.jupiter.api.Test;
|
|
import java.time.*;
|
|
import static org.assertj.core.api.Assertions.*;
|
|
|
|
class NotificationRetryPolicyTest {
|
|
@Test
|
|
void authenticationFailureStopsAndOpensProvider() {
|
|
var decision = policy().decide(PolicyFixture.authFailure());
|
|
assertThat(decision).isInstanceOf(RetryDecision.Stop.class);
|
|
}
|
|
|
|
@Test
|
|
void ambiguousWithoutReconcileOrProviderIdempotencyStops() {
|
|
var decision = policy().decide(PolicyFixture.ambiguousUnsafe());
|
|
assertThat(decision).isInstanceOf(RetryDecision.Stop.class);
|
|
}
|
|
|
|
@Test
|
|
void nextBackoffBeyondExpiryExpiresDelivery() {
|
|
var decision = policy().decide(PolicyFixture.transientWithExpiry(
|
|
Instant.parse("2026-08-10T00:00:01Z")));
|
|
assertThat(((RetryDecision.Stop) decision).reasonCode()).isEqualTo("EXPIRED");
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `./gradlew :modules:notification:notification-policy:test --tests "*.NotificationRetryPolicyTest"`
|
|
|
|
Expected: FAIL: retry policy가 없다.
|
|
|
|
- [ ] **Step 3: Write the minimal implementation**
|
|
|
|
Files to implement:
|
|
- `RetryContext.java`
|
|
- `RetryDecision.java`
|
|
- `NotificationRetryPolicy.java`
|
|
- `RetryBudget.java`
|
|
|
|
```java
|
|
public sealed interface RetryDecision {
|
|
record RetryAfter(java.time.Duration delay) implements RetryDecision {}
|
|
record Reconcile(java.time.Instant at) implements RetryDecision {}
|
|
record Fallback(String reasonCode) implements RetryDecision {}
|
|
record Stop(String reasonCode) implements RetryDecision {}
|
|
}
|
|
|
|
public RetryDecision decide(RetryContext context) {
|
|
if (context.failureCategory() == FailureCategory.AUTHENTICATION
|
|
|| context.failureCategory() == FailureCategory.AUTHORIZATION) {
|
|
return new RetryDecision.Stop("PROVIDER_CONFIGURATION_FAILURE");
|
|
}
|
|
if (context.confirmation() == AttemptConfirmation.AMBIGUOUS) {
|
|
if (context.statusQuerySupported()) return new RetryDecision.Reconcile(context.nextReconcileAt());
|
|
if (!context.providerIdempotency()) return new RetryDecision.Stop("AMBIGUOUS_UNSAFE_TO_RETRY");
|
|
}
|
|
var delay = backoff.delay(context.attemptNumber());
|
|
if (!context.budget().canConsume() || !context.canFinishBeforeExpiry(delay)) {
|
|
return new RetryDecision.Stop("EXPIRED_OR_BUDGET_EXHAUSTED");
|
|
}
|
|
return new RetryDecision.RetryAfter(delay);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `./gradlew :modules:notification:notification-policy:test`
|
|
|
|
Expected: PASS: auth/ambiguity/expiry/budget 규칙이 정확히 판정된다.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add 'modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/RetryContext.java' 'modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/RetryDecision.java' 'modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/NotificationRetryPolicy.java' 'modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/RetryBudget.java' 'modules/notification/notification-policy/src/test/java/io/backend/skeleton/notification/policy/NotificationRetryPolicyTest.java'
|
|
git commit -m "feat(notification): add evidence-aware retry policy"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 20: Provider Runtime Registry, Health, Rate·Concurrency Guard 구현
|
|
|
|
**Files:**
|
|
- Create: `modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/ProviderRuntimeRegistry.java`
|
|
- Create: `modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/ProviderRuntime.java`
|
|
- Create: `modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/ProviderRuntimeState.java`
|
|
- Create: `modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/ProviderAttemptLimiter.java`
|
|
- Test: `modules/notification/notification-dispatch-runtime/src/test/java/io/backend/skeleton/notification/dispatch/ProviderRuntimeRegistryTest.java`
|
|
|
|
**Interfaces:**
|
|
- Consumes: Task 10 adapter capability, Task 19 retry policy.
|
|
- Produces: Immutable generation runtime, provider health gate, per-attempt rate and concurrency permits.
|
|
|
|
**Implementation requirements:**
|
|
- Rate limiter는 실제 provider attempt마다 token을 소비한다.
|
|
- backoff 중 concurrency permit을 점유하지 않는다.
|
|
- credential generation을 DeliveryAttempt에 기록한다.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```java
|
|
package io.backend.skeleton.notification.dispatch;
|
|
|
|
import org.junit.jupiter.api.Test;
|
|
import static org.assertj.core.api.Assertions.*;
|
|
|
|
class ProviderRuntimeRegistryTest {
|
|
@Test
|
|
void authenticationFailureRejectsNewAttemptsWithoutConsumingPermit() {
|
|
var runtime = RuntimeFixture.runtime();
|
|
runtime.markAuthenticationFailed("INVALID_CREDENTIAL");
|
|
assertThatThrownBy(runtime::acquireAttempt)
|
|
.isInstanceOf(ProviderUnavailableException.class);
|
|
assertThat(runtime.activeAttempts()).isZero();
|
|
}
|
|
|
|
@Test
|
|
void replacementKeepsOldRuntimeDraining() {
|
|
var registry = RuntimeFixture.registryWithGeneration(1);
|
|
var old = registry.current("ses-primary");
|
|
registry.replace(RuntimeFixture.runtime(2));
|
|
assertThat(registry.current("ses-primary").generation()).isEqualTo(2);
|
|
assertThat(old.state()).isEqualTo(ProviderRuntimeState.DRAINING);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `./gradlew :modules:notification:notification-dispatch-runtime:test --tests "*.ProviderRuntimeRegistryTest"`
|
|
|
|
Expected: FAIL: runtime registry와 limiter가 없다.
|
|
|
|
- [ ] **Step 3: Write the minimal implementation**
|
|
|
|
Files to implement:
|
|
- `ProviderRuntimeRegistry.java`
|
|
- `ProviderRuntime.java`
|
|
- `ProviderRuntimeState.java`
|
|
- `ProviderAttemptLimiter.java`
|
|
|
|
```java
|
|
public enum ProviderRuntimeState {
|
|
HEALTHY, DEGRADED, THROTTLED, AUTHENTICATION_FAILED, DISABLED, DRAINING
|
|
}
|
|
|
|
public final class ProviderRuntime {
|
|
private final long generation;
|
|
private final NotificationProviderAdapter adapter;
|
|
private final ProviderAttemptLimiter limiter;
|
|
private final java.util.concurrent.atomic.AtomicReference<ProviderRuntimeState> state;
|
|
|
|
public AttemptPermit acquireAttempt() {
|
|
var current = state.get();
|
|
if (current == ProviderRuntimeState.AUTHENTICATION_FAILED
|
|
|| current == ProviderRuntimeState.DISABLED
|
|
|| current == ProviderRuntimeState.DRAINING) {
|
|
throw new ProviderUnavailableException(current.name());
|
|
}
|
|
return limiter.acquire();
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `./gradlew :modules:notification:notification-dispatch-runtime:test`
|
|
|
|
Expected: PASS: auth failure가 fail-fast하고 runtime replacement가 generation drain을 수행한다.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add 'modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/ProviderRuntimeRegistry.java' 'modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/ProviderRuntime.java' 'modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/ProviderRuntimeState.java' 'modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/ProviderAttemptLimiter.java' 'modules/notification/notification-dispatch-runtime/src/test/java/io/backend/skeleton/notification/dispatch/ProviderRuntimeRegistryTest.java'
|
|
git commit -m "feat(notification): add provider runtime isolation and limits"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 21: Dispatch Orchestrator와 Ambiguous Completion 기록 구현
|
|
|
|
**Files:**
|
|
- Create: `modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/NotificationDispatcher.java`
|
|
- Create: `modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/DeliveryAttemptFactory.java`
|
|
- Create: `modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/DispatchOutcomeRecorder.java`
|
|
- Create: `modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/DispatchPipeline.java`
|
|
- Test: `modules/notification/notification-dispatch-runtime/src/test/java/io/backend/skeleton/notification/dispatch/NotificationDispatcherAmbiguityTest.java`
|
|
|
|
**Interfaces:**
|
|
- Consumes: Tasks 16 scheduler, 17 eligibility, 18 routing, 19 retry, 20 runtime registry.
|
|
- Produces: Attempt-before-call transaction, provider call outside transaction, confirmed/rejected/ambiguous outcome recording.
|
|
|
|
**Implementation requirements:**
|
|
- Provider call을 @Transactional method 내부에서 실행하지 않는다.
|
|
- process crash 후 DISPATCHING attempt는 lease recovery/reconciliation 대상이다.
|
|
- Outcome record와 next action은 idempotent하게 재실행 가능해야 한다.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```java
|
|
package io.backend.skeleton.notification.dispatch;
|
|
|
|
import org.junit.jupiter.api.Test;
|
|
import static org.assertj.core.api.Assertions.*;
|
|
|
|
class NotificationDispatcherAmbiguityTest extends PostgreSqlNotificationTest {
|
|
@Test
|
|
void providerAcceptsThenResponseIsLostRecordsAmbiguousAndBlocksFallback() {
|
|
var provider = ProviderFixture.acceptThenResetConnection();
|
|
dispatcher(provider).dispatch(fixture().leasedRecipient());
|
|
|
|
var attempt = lastAttempt();
|
|
var recipient = loadRecipient(attempt.recipientDeliveryId());
|
|
assertThat(attempt.submissionOutcome()).isEqualTo(SubmissionOutcome.AMBIGUOUS);
|
|
assertThat(attempt.confirmation()).isEqualTo(AttemptConfirmation.AMBIGUOUS);
|
|
assertThat(recipient.ambiguousAttemptExists()).isTrue();
|
|
assertThat(recipient.deliveryState()).isEqualTo(RecipientDeliveryState.RECONCILIATION_REQUIRED);
|
|
assertThat(provider.calls()).isEqualTo(1);
|
|
}
|
|
|
|
@Test
|
|
void attemptRowExistsBeforeProviderInvocation() {
|
|
dispatcher(ProviderFixture.assertAttemptExists(repository()))
|
|
.dispatch(fixture().leasedRecipient());
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `./gradlew :modules:notification:notification-dispatch-runtime:test --tests "*.NotificationDispatcherAmbiguityTest"`
|
|
|
|
Expected: FAIL: dispatcher와 outcome recorder가 없다.
|
|
|
|
- [ ] **Step 3: Write the minimal implementation**
|
|
|
|
Files to implement:
|
|
- `NotificationDispatcher.java`
|
|
- `DeliveryAttemptFactory.java`
|
|
- `DispatchOutcomeRecorder.java`
|
|
- `DispatchPipeline.java`
|
|
|
|
```java
|
|
public void dispatch(RecipientLease lease) {
|
|
var snapshot = loader.load(lease.recipientDeliveryId());
|
|
guards.verifyNotExpiredAndEligible(snapshot);
|
|
var route = routing.next(snapshot.routingContext());
|
|
var attempt = attempts.createAndCommit(snapshot, route.selected().orElseThrow());
|
|
|
|
ProviderSubmissionResult result;
|
|
try (var permit = runtimes.current(attempt.providerProfileId()).acquireAttempt()) {
|
|
result = runtimes.current(attempt.providerProfileId())
|
|
.adapter().submit(submissionFactory.from(attempt)).toCompletableFuture().join();
|
|
} catch (ProviderTransportException error) {
|
|
result = evidenceClassifier.classify(error);
|
|
}
|
|
recorder.record(attempt.id(), result);
|
|
nextAction.apply(attempt.id(), retryPolicy.decide(contextFactory.from(attempt.id())));
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `./gradlew :modules:notification:notification-dispatch-runtime:test`
|
|
|
|
Expected: PASS: response loss가 AMBIGUOUS로 저장되고 provider call 전에 Attempt row가 존재한다.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add 'modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/NotificationDispatcher.java' 'modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/DeliveryAttemptFactory.java' 'modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/DispatchOutcomeRecorder.java' 'modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/DispatchPipeline.java' 'modules/notification/notification-dispatch-runtime/src/test/java/io/backend/skeleton/notification/dispatch/NotificationDispatcherAmbiguityTest.java'
|
|
git commit -m "feat(notification): implement evidence-aware dispatch pipeline"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 22: Callback Verification·Normalization·Ingestion Core 구현
|
|
|
|
**Files:**
|
|
- Create: `modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/ProviderCallbackAdapter.java`
|
|
- Create: `modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/CallbackRequest.java`
|
|
- Create: `modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/CallbackVerificationResult.java`
|
|
- Create: `modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/NormalizedProviderEvent.java`
|
|
- Create: `modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/VerifiedCallback.java`
|
|
- Create: `modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/CallbackIngestionResult.java`
|
|
- Create: `modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/ProviderCallbackIngestionService.java`
|
|
- Test: `modules/notification/notification-callback-api/src/test/java/io/backend/skeleton/notification/callback/ProviderCallbackIngestionServiceTest.java`
|
|
|
|
**Interfaces:**
|
|
- Consumes: Task 14 event ledger/projector, Task 11 callback error category.
|
|
- Produces: Signature-first callback service, bounded raw append, duplicate no-op, unknown field tolerance.
|
|
|
|
**Implementation requirements:**
|
|
- Signature 검증에 필요한 raw bytes와 external URL을 decoding 전에 보존한다.
|
|
- unknown JSON field로 parsing을 실패시키지 않는다.
|
|
- Callback raw body hard limit을 Provider profile보다 크게 설정하지 않는다.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```java
|
|
package io.backend.skeleton.notification.callback;
|
|
|
|
import org.junit.jupiter.api.Test;
|
|
import static org.assertj.core.api.Assertions.*;
|
|
|
|
class ProviderCallbackIngestionServiceTest {
|
|
@Test
|
|
void invalidSignatureNeverChangesProjection() {
|
|
var service = CallbackFixture.serviceWithInvalidSignature();
|
|
assertThatThrownBy(() -> service.ingest(CallbackFixture.request()))
|
|
.isInstanceOf(CallbackValidationException.class);
|
|
assertThat(CallbackFixture.ledger().count()).isZero();
|
|
assertThat(CallbackFixture.projectionWrites()).isZero();
|
|
}
|
|
|
|
@Test
|
|
void duplicateEventIsAcknowledgedWithoutSecondProjection() {
|
|
var service = CallbackFixture.service();
|
|
var request = CallbackFixture.request("event-1");
|
|
service.ingest(request);
|
|
var duplicate = service.ingest(request);
|
|
assertThat(duplicate.duplicate()).isTrue();
|
|
assertThat(CallbackFixture.projectionWrites()).isEqualTo(1);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `./gradlew :modules:notification:notification-callback-api:test --tests "*.ProviderCallbackIngestionServiceTest"`
|
|
|
|
Expected: FAIL: callback ingestion core가 없다.
|
|
|
|
- [ ] **Step 3: Write the minimal implementation**
|
|
|
|
Files to implement:
|
|
- `ProviderCallbackAdapter.java`
|
|
- `CallbackRequest.java`
|
|
- `CallbackVerificationResult.java`
|
|
- `NormalizedProviderEvent.java`
|
|
- `VerifiedCallback.java`
|
|
- `CallbackIngestionResult.java`
|
|
- `ProviderCallbackIngestionService.java`
|
|
|
|
```java
|
|
public CallbackIngestionResult ingest(CallbackRequest request) {
|
|
callbackLimits.validate(request.contentType(), request.body().length);
|
|
var adapter = adapters.require(request.providerProfileId());
|
|
var verification = adapter.verify(request);
|
|
if (!verification.valid()) {
|
|
securityAudit.signatureRejected(request.providerProfileId(), verification.reasonCode());
|
|
throw new CallbackValidationException(verification.reasonCode());
|
|
}
|
|
var normalized = adapter.normalize(verification.verifiedCallback());
|
|
var append = ledger.appendAll(normalized);
|
|
append.newEvents().forEach(projectorService::project);
|
|
return new CallbackIngestionResult(append.newEvents().size(), append.duplicates().size());
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `./gradlew :modules:notification:notification-callback-api:test`
|
|
|
|
Expected: PASS: invalid signature는 상태를 바꾸지 않고 duplicate callback은 한 번만 projection된다.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add 'modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/ProviderCallbackAdapter.java' 'modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/CallbackRequest.java' 'modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/CallbackVerificationResult.java' 'modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/NormalizedProviderEvent.java' 'modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/VerifiedCallback.java' 'modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/CallbackIngestionResult.java' 'modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/ProviderCallbackIngestionService.java' 'modules/notification/notification-callback-api/src/test/java/io/backend/skeleton/notification/callback/ProviderCallbackIngestionServiceTest.java'
|
|
git commit -m "feat(notification): add secure callback ingestion core"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 23: Spring MVC Provider Callback Endpoint 구현
|
|
|
|
**Files:**
|
|
- Create: `modules/notification/notification-callback-mvc/src/main/java/io/backend/skeleton/notification/callback/mvc/NotificationCallbackMvcController.java`
|
|
- Create: `modules/notification/notification-callback-mvc/src/main/java/io/backend/skeleton/notification/callback/mvc/ExternalRequestUrlResolver.java`
|
|
- Create: `modules/notification/notification-callback-mvc/src/main/java/io/backend/skeleton/notification/callback/mvc/CallbackMvcSecurityConfiguration.java`
|
|
- Test: `modules/notification/notification-callback-mvc/src/test/java/io/backend/skeleton/notification/callback/mvc/NotificationCallbackMvcControllerTest.java`
|
|
|
|
**Interfaces:**
|
|
- Consumes: Task 22 CallbackIngestionService.
|
|
- Produces: Bounded MVC endpoint, raw body preservation, profile path binding, fast 2xx response.
|
|
|
|
**Implementation requirements:**
|
|
- Forwarded header를 무조건 신뢰하지 않고 trusted proxy 설정과 결합한다.
|
|
- callback endpoint는 일반 user session security chain과 분리한다.
|
|
- raw body를 일반 access log에 기록하지 않는다.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```java
|
|
package io.backend.skeleton.notification.callback.mvc;
|
|
|
|
import org.junit.jupiter.api.Test;
|
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
|
|
|
class NotificationCallbackMvcControllerTest extends CallbackMvcTestBase {
|
|
@Test
|
|
void rejectsOversizedBodyBeforeIngestion() throws Exception {
|
|
mockMvc.perform(post("/internal/notification/callbacks/twilio/twilio-primary")
|
|
.contentType("application/x-www-form-urlencoded")
|
|
.content(new byte[65537]))
|
|
.andExpect(status().isPayloadTooLarge());
|
|
verifyNoIngestion();
|
|
}
|
|
|
|
@Test
|
|
void passesExternallyVisibleUrlForSignatureVerification() throws Exception {
|
|
mockMvc.perform(post("/internal/notification/callbacks/twilio/twilio-primary")
|
|
.header("Forwarded", "proto=https;host=callback.example.com")
|
|
.contentType("application/x-www-form-urlencoded")
|
|
.content("MessageSid=SM1&MessageStatus=delivered"))
|
|
.andExpect(status().isNoContent());
|
|
verifyExternalUrl("https://callback.example.com/internal/notification/callbacks/twilio/twilio-primary");
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `./gradlew :modules:notification:notification-callback-mvc:test --tests "*.NotificationCallbackMvcControllerTest"`
|
|
|
|
Expected: FAIL: MVC callback controller가 없다.
|
|
|
|
- [ ] **Step 3: Write the minimal implementation**
|
|
|
|
Files to implement:
|
|
- `NotificationCallbackMvcController.java`
|
|
- `ExternalRequestUrlResolver.java`
|
|
- `CallbackMvcSecurityConfiguration.java`
|
|
|
|
```java
|
|
@RestController
|
|
@RequestMapping("/internal/notification/callbacks")
|
|
final class NotificationCallbackMvcController {
|
|
@PostMapping(path = "/{provider}/{profile}")
|
|
ResponseEntity<Void> callback(
|
|
@PathVariable String provider,
|
|
@PathVariable String profile,
|
|
HttpServletRequest request,
|
|
@RequestBody byte[] body) {
|
|
var callback = requestFactory.create(provider, profile, request, body);
|
|
ingestion.ingest(callback);
|
|
return ResponseEntity.noContent().build();
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `./gradlew :modules:notification:notification-callback-mvc:test`
|
|
|
|
Expected: PASS: body limit, external URL, profile binding, 204 response가 검증된다.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add 'modules/notification/notification-callback-mvc/src/main/java/io/backend/skeleton/notification/callback/mvc/NotificationCallbackMvcController.java' 'modules/notification/notification-callback-mvc/src/main/java/io/backend/skeleton/notification/callback/mvc/ExternalRequestUrlResolver.java' 'modules/notification/notification-callback-mvc/src/main/java/io/backend/skeleton/notification/callback/mvc/CallbackMvcSecurityConfiguration.java' 'modules/notification/notification-callback-mvc/src/test/java/io/backend/skeleton/notification/callback/mvc/NotificationCallbackMvcControllerTest.java'
|
|
git commit -m "feat(notification): add MVC callback endpoints"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 24: Spring WebFlux Provider Callback Endpoint 구현
|
|
|
|
**Files:**
|
|
- Create: `modules/notification/notification-callback-webflux/src/main/java/io/backend/skeleton/notification/callback/webflux/NotificationCallbackWebFluxHandler.java`
|
|
- Create: `modules/notification/notification-callback-webflux/src/main/java/io/backend/skeleton/notification/callback/webflux/CallbackWebFluxRouter.java`
|
|
- Create: `modules/notification/notification-callback-webflux/src/main/java/io/backend/skeleton/notification/callback/webflux/BoundedCallbackBodyReader.java`
|
|
- Test: `modules/notification/notification-callback-webflux/src/test/java/io/backend/skeleton/notification/callback/webflux/NotificationCallbackWebFluxHandlerTest.java`
|
|
|
|
**Interfaces:**
|
|
- Consumes: Task 22 CallbackIngestionService.
|
|
- Produces: WebFlux raw bytes reader with buffer release, bounded size, boundedElastic ingestion bridge.
|
|
|
|
**Implementation requirements:**
|
|
- blocking JPA ingestion을 Netty event-loop에서 실행하지 않는다.
|
|
- MVC와 WebFlux가 같은 CallbackRequest canonicalization을 사용한다.
|
|
- body-to-string 변환 전 signature verification용 bytes를 유지한다.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```java
|
|
package io.backend.skeleton.notification.callback.webflux;
|
|
|
|
import org.junit.jupiter.api.Test;
|
|
import reactor.test.StepVerifier;
|
|
import static org.assertj.core.api.Assertions.*;
|
|
|
|
class NotificationCallbackWebFluxHandlerTest extends CallbackWebFluxTestBase {
|
|
@Test
|
|
void releasesBuffersWhenBodyLimitIsExceeded() {
|
|
StepVerifier.create(client().post()
|
|
.uri("/internal/notification/callbacks/ses/ses-primary")
|
|
.bodyValue(new byte[65537])
|
|
.exchangeToMono(response -> response.releaseBody().thenReturn(response.statusCode())))
|
|
.expectNextMatches(status -> status.value() == 413)
|
|
.verifyComplete();
|
|
assertThat(leakDetector().activeBuffers()).isZero();
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `./gradlew :modules:notification:notification-callback-webflux:test --tests "*.NotificationCallbackWebFluxHandlerTest"`
|
|
|
|
Expected: FAIL: WebFlux callback router와 bounded reader가 없다.
|
|
|
|
- [ ] **Step 3: Write the minimal implementation**
|
|
|
|
Files to implement:
|
|
- `NotificationCallbackWebFluxHandler.java`
|
|
- `CallbackWebFluxRouter.java`
|
|
- `BoundedCallbackBodyReader.java`
|
|
|
|
```java
|
|
public Mono<ServerResponse> handle(ServerRequest request) {
|
|
return bodyReader.read(request.exchange().getRequest())
|
|
.publishOn(Schedulers.boundedElastic())
|
|
.map(bytes -> callbackFactory.create(request, bytes))
|
|
.doOnNext(ingestion::ingest)
|
|
.then(ServerResponse.noContent().build());
|
|
}
|
|
|
|
// BoundedCallbackBodyReader accumulates at most configured bytes and
|
|
// releases every DataBuffer on success, error and cancellation.
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `./gradlew :modules:notification:notification-callback-webflux:test`
|
|
|
|
Expected: PASS: body limit 초과와 cancellation 경로에서 DataBuffer leak가 없다.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add 'modules/notification/notification-callback-webflux/src/main/java/io/backend/skeleton/notification/callback/webflux/NotificationCallbackWebFluxHandler.java' 'modules/notification/notification-callback-webflux/src/main/java/io/backend/skeleton/notification/callback/webflux/CallbackWebFluxRouter.java' 'modules/notification/notification-callback-webflux/src/main/java/io/backend/skeleton/notification/callback/webflux/BoundedCallbackBodyReader.java' 'modules/notification/notification-callback-webflux/src/test/java/io/backend/skeleton/notification/callback/webflux/NotificationCallbackWebFluxHandlerTest.java'
|
|
git commit -m "feat(notification): add WebFlux callback endpoints"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 25: Provider Reconciliation Framework 구현
|
|
|
|
**Files:**
|
|
- Create: `modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/ReconciliationCapability.java`
|
|
- Create: `modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/ReconciliationResult.java`
|
|
- Create: `modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/ReconciliationScheduler.java`
|
|
- Create: `modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/ReconciliationService.java`
|
|
- Test: `modules/notification/notification-dispatch-runtime/src/test/java/io/backend/skeleton/notification/dispatch/ReconciliationServiceTest.java`
|
|
|
|
**Interfaces:**
|
|
- Consumes: Task 14 ledger/projector, Task 20 runtime registry, Task 21 attempt persistence.
|
|
- Produces: Capability-aware reconcile, synthetic event append, bounded retries and correction audit.
|
|
|
|
**Implementation requirements:**
|
|
- Reconciliation query도 provider rate/concurrency limit을 사용한다.
|
|
- synthetic event가 기존 강한 evidence를 downgrade하지 않는다.
|
|
- max reconciliation age와 attempts를 bounded property로 둔다.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```java
|
|
package io.backend.skeleton.notification.dispatch;
|
|
|
|
import org.junit.jupiter.api.Test;
|
|
import static org.assertj.core.api.Assertions.*;
|
|
|
|
class ReconciliationServiceTest extends PostgreSqlNotificationTest {
|
|
@Test
|
|
void confirmedQueryResultIsAppendedAsSyntheticEvent() {
|
|
var attempt = insertAmbiguousAttempt();
|
|
service(ProviderFixture.reconcileDelivered()).reconcile(attempt.id());
|
|
var event = lastProviderEvent();
|
|
assertThat(event.source()).isEqualTo(ProviderEventSource.RECONCILIATION);
|
|
assertThat(loadRecipient(attempt.recipientDeliveryId()).deliveryOutcome())
|
|
.isEqualTo(DeliveryOutcome.DELIVERED);
|
|
}
|
|
|
|
@Test
|
|
void unsupportedProviderLeavesAttemptUnknown() {
|
|
var attempt = insertAmbiguousAttempt();
|
|
service(ProviderFixture.noReconciliation()).reconcile(attempt.id());
|
|
assertThat(loadAttempt(attempt.id()).submissionOutcome())
|
|
.isEqualTo(SubmissionOutcome.AMBIGUOUS);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `./gradlew :modules:notification:notification-dispatch-runtime:test --tests "*.ReconciliationServiceTest"`
|
|
|
|
Expected: FAIL: reconciliation capability와 service가 없다.
|
|
|
|
- [ ] **Step 3: Write the minimal implementation**
|
|
|
|
Files to implement:
|
|
- `ReconciliationCapability.java`
|
|
- `ReconciliationResult.java`
|
|
- `ReconciliationScheduler.java`
|
|
- `ReconciliationService.java`
|
|
|
|
```java
|
|
public interface ReconciliationCapability {
|
|
boolean supports(ProviderProfileSnapshot profile);
|
|
CompletionStage<ReconciliationResult> reconcile(DeliveryAttemptSnapshot attempt);
|
|
}
|
|
|
|
public void reconcile(DeliveryAttemptId attemptId) {
|
|
var attempt = attempts.load(attemptId);
|
|
var runtime = runtimes.current(attempt.providerProfileId());
|
|
var capability = runtime.reconciliationCapability();
|
|
var result = capability.reconcile(attempt).toCompletableFuture().join();
|
|
switch (result) {
|
|
case ReconciliationResult.Confirmed confirmed ->
|
|
ledger.append(confirmed.asSyntheticEvent(ProviderEventSource.RECONCILIATION));
|
|
case ReconciliationResult.StillUnknown unknown -> schedule(attemptId, unknown.nextCheckAt());
|
|
case ReconciliationResult.Unsupported ignored -> markUnsupported(attemptId);
|
|
case ReconciliationResult.Failed failed -> handleFailure(attemptId, failed);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `./gradlew :modules:notification:notification-dispatch-runtime:test`
|
|
|
|
Expected: PASS: confirmed result는 ledger를 통하고 unsupported result는 추정하지 않는다.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add 'modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/ReconciliationCapability.java' 'modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/ReconciliationResult.java' 'modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/ReconciliationScheduler.java' 'modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/ReconciliationService.java' 'modules/notification/notification-dispatch-runtime/src/test/java/io/backend/skeleton/notification/dispatch/ReconciliationServiceTest.java'
|
|
git commit -m "feat(notification): add provider reconciliation framework"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 26: Attachment Reference Resolver와 Integrity Guard 구현
|
|
|
|
**Files:**
|
|
- Create: `modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/AttachmentResolver.java`
|
|
- Create: `modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/ResolvedAttachment.java`
|
|
- Create: `modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/AttachmentAccessContext.java`
|
|
- Create: `modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/AttachmentIntegrityGuard.java`
|
|
- Test: `modules/notification/notification-dispatch-runtime/src/test/java/io/backend/skeleton/notification/dispatch/AttachmentIntegrityGuardTest.java`
|
|
|
|
**Interfaces:**
|
|
- Consumes: Task 3 AttachmentRef, existing fileserver/objectstorage contracts.
|
|
- Produces: READY/authorization/size/digest checked immutable attachment stream, no byte persistence.
|
|
|
|
**Implementation requirements:**
|
|
- attachment stream은 try-with-resources로 닫는다.
|
|
- signed URL을 persistence/log에 보존하지 않는다.
|
|
- Provider retry마다 immutable source를 다시 열 수 있어야 한다.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```java
|
|
package io.backend.skeleton.notification.dispatch;
|
|
|
|
import org.junit.jupiter.api.Test;
|
|
import static org.assertj.core.api.Assertions.*;
|
|
|
|
class AttachmentIntegrityGuardTest {
|
|
@Test
|
|
void rejectsFileThatIsNotReadyBeforeProviderCall() {
|
|
var resolver = AttachmentFixture.notReadyResolver();
|
|
assertThatThrownBy(() -> guard(resolver).resolve(AttachmentFixture.ref()))
|
|
.isInstanceOf(AttachmentUnavailableException.class);
|
|
assertThat(AttachmentFixture.providerCalls()).isZero();
|
|
}
|
|
|
|
@Test
|
|
void rejectsDigestMismatch() {
|
|
assertThatThrownBy(() -> guard(AttachmentFixture.digestMismatchResolver())
|
|
.resolve(AttachmentFixture.ref()))
|
|
.isInstanceOf(AttachmentIntegrityException.class);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `./gradlew :modules:notification:notification-dispatch-runtime:test --tests "*.AttachmentIntegrityGuardTest"`
|
|
|
|
Expected: FAIL: attachment resolver와 guard가 없다.
|
|
|
|
- [ ] **Step 3: Write the minimal implementation**
|
|
|
|
Files to implement:
|
|
- `AttachmentResolver.java`
|
|
- `ResolvedAttachment.java`
|
|
- `AttachmentAccessContext.java`
|
|
- `AttachmentIntegrityGuard.java`
|
|
|
|
```java
|
|
public interface AttachmentResolver {
|
|
ResolvedAttachment resolve(AttachmentRef reference, AttachmentAccessContext context);
|
|
}
|
|
|
|
public record ResolvedAttachment(
|
|
java.io.InputStream content,
|
|
long size,
|
|
String digest,
|
|
String contentType,
|
|
String displayName
|
|
) implements AutoCloseable {
|
|
@Override public void close() throws java.io.IOException { content.close(); }
|
|
}
|
|
|
|
public ResolvedAttachment resolve(AttachmentRef ref) {
|
|
var attachment = resolver.resolve(ref, accessContext);
|
|
if (attachment.size() != ref.expectedSize()) throw new AttachmentIntegrityException("SIZE_MISMATCH");
|
|
if (!constantTimeEquals(attachment.digest(), ref.expectedDigest()))
|
|
throw new AttachmentIntegrityException("DIGEST_MISMATCH");
|
|
return attachment;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `./gradlew :modules:notification:notification-dispatch-runtime:test`
|
|
|
|
Expected: PASS: READY·authorization·size·digest가 provider call 전에 검증된다.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add 'modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/AttachmentResolver.java' 'modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/ResolvedAttachment.java' 'modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/AttachmentAccessContext.java' 'modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/AttachmentIntegrityGuard.java' 'modules/notification/notification-dispatch-runtime/src/test/java/io/backend/skeleton/notification/dispatch/AttachmentIntegrityGuardTest.java'
|
|
git commit -m "feat(notification): add safe attachment reference resolution"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 27: SMTP Provider Adapter와 MIME 구현
|
|
|
|
**Files:**
|
|
- Create: `modules/notification/notification-email-smtp/src/main/java/io/backend/skeleton/notification/email/smtp/SmtpNotificationProviderAdapter.java`
|
|
- Create: `modules/notification/notification-email-smtp/src/main/java/io/backend/skeleton/notification/email/smtp/SmtpMimeMessageFactory.java`
|
|
- Create: `modules/notification/notification-email-smtp/src/main/java/io/backend/skeleton/notification/email/smtp/SmtpFailureClassifier.java`
|
|
- Create: `modules/notification/notification-email-smtp/src/main/java/io/backend/skeleton/notification/email/smtp/SmtpProviderProperties.java`
|
|
- Test: `modules/notification/notification-email-smtp/src/test/java/io/backend/skeleton/notification/email/smtp/SmtpNotificationProviderAdapterTest.java`
|
|
|
|
**Interfaces:**
|
|
- Consumes: Task 10 provider SPI, Task 26 attachment resolver, Task 30 Email content contract.
|
|
- Produces: SMTP submit, multipart MIME, bounded timeouts, 4yz/5yz and ambiguous final-response classification.
|
|
|
|
**Implementation requirements:**
|
|
- SMTP connection/read/write timeout을 모두 유한값으로 강제한다.
|
|
- header CRLF injection을 MIME 생성 전 거부한다.
|
|
- JavaMail exception 원문에 recipient가 포함되면 logging sanitizer로 제거한다.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```java
|
|
package io.backend.skeleton.notification.email.smtp;
|
|
|
|
import org.junit.jupiter.api.Test;
|
|
import static org.assertj.core.api.Assertions.*;
|
|
|
|
class SmtpNotificationProviderAdapterTest extends SmtpTestServerBase {
|
|
@Test
|
|
void final250MeansProviderAcceptedNotDelivered() {
|
|
smtpServer().respondAfterData(250, "queued");
|
|
var result = adapter().submit(fixture().emailSubmission()).toCompletableFuture().join();
|
|
assertThat(result.submissionOutcome()).isEqualTo(SubmissionOutcome.CONFIRMED_ACCEPTED);
|
|
assertThat(result.evidenceLevel()).isEqualTo(EvidenceLevel.PROVIDER_ACCEPTED);
|
|
assertThat(result.deliveryOutcome()).isEqualTo(DeliveryOutcome.UNKNOWN);
|
|
}
|
|
|
|
@Test
|
|
void connectionLossAfterDataIsAmbiguous() {
|
|
smtpServer().acceptDataThenCloseWithoutResponse();
|
|
var result = adapter().submit(fixture().emailSubmission()).toCompletableFuture().join();
|
|
assertThat(result.confirmation()).isEqualTo(AttemptConfirmation.AMBIGUOUS);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `./gradlew :modules:notification:notification-email-smtp:test --tests "*.SmtpNotificationProviderAdapterTest"`
|
|
|
|
Expected: FAIL: SMTP adapter와 MIME factory가 없다.
|
|
|
|
- [ ] **Step 3: Write the minimal implementation**
|
|
|
|
Files to implement:
|
|
- `SmtpNotificationProviderAdapter.java`
|
|
- `SmtpMimeMessageFactory.java`
|
|
- `SmtpFailureClassifier.java`
|
|
- `SmtpProviderProperties.java`
|
|
|
|
```java
|
|
public CompletionStage<ProviderSubmissionResult> submit(ProviderSubmission submission) {
|
|
return CompletableFuture.supplyAsync(() -> {
|
|
try (var attachments = attachmentScope.open(submission)) {
|
|
var message = mimeFactory.create(submission, attachments);
|
|
javaMailSender.send(message);
|
|
return results.accepted(EvidenceLevel.PROVIDER_ACCEPTED);
|
|
} catch (MailSendException error) {
|
|
return classifier.classify(error);
|
|
}
|
|
}, smtpExecutor);
|
|
}
|
|
|
|
// SmtpFailureClassifier maps 4yz to TRANSIENT_PROVIDER, 5yz to permanent or
|
|
// invalid recipient, and loss after DATA commitment to AMBIGUOUS_SUBMISSION.
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `./gradlew :modules:notification:notification-email-smtp:test`
|
|
|
|
Expected: PASS: SMTP acceptance, MIME, transient/permanent, ambiguous response가 분리된다.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add 'modules/notification/notification-email-smtp/src/main/java/io/backend/skeleton/notification/email/smtp/SmtpNotificationProviderAdapter.java' 'modules/notification/notification-email-smtp/src/main/java/io/backend/skeleton/notification/email/smtp/SmtpMimeMessageFactory.java' 'modules/notification/notification-email-smtp/src/main/java/io/backend/skeleton/notification/email/smtp/SmtpFailureClassifier.java' 'modules/notification/notification-email-smtp/src/main/java/io/backend/skeleton/notification/email/smtp/SmtpProviderProperties.java' 'modules/notification/notification-email-smtp/src/test/java/io/backend/skeleton/notification/email/smtp/SmtpNotificationProviderAdapterTest.java'
|
|
git commit -m "feat(notification): add stable SMTP email adapter"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 28: Amazon SES Submit Adapter 구현
|
|
|
|
**Files:**
|
|
- Create: `modules/notification/notification-email-ses/src/main/java/io/backend/skeleton/notification/email/ses/SesNotificationProviderAdapter.java`
|
|
- Create: `modules/notification/notification-email-ses/src/main/java/io/backend/skeleton/notification/email/ses/SesRequestMapper.java`
|
|
- Create: `modules/notification/notification-email-ses/src/main/java/io/backend/skeleton/notification/email/ses/SesFailureClassifier.java`
|
|
- Create: `modules/notification/notification-email-ses/src/main/java/io/backend/skeleton/notification/email/ses/SesProviderProperties.java`
|
|
- Test: `modules/notification/notification-email-ses/src/test/java/io/backend/skeleton/notification/email/ses/SesNotificationProviderAdapterTest.java`
|
|
|
|
**Interfaces:**
|
|
- Consumes: Task 10 provider SPI, Task 20 runtime, existing httpclient platform.
|
|
- Produces: SES MessageId mapping, acceptance-only evidence, HTTP ambiguity and error classification.
|
|
|
|
**Implementation requirements:**
|
|
- SES retry owner는 notification policy이며 HTTP client profile의 blind retry를 비활성화한다.
|
|
- configuration set은 approved N3 option으로만 전달한다.
|
|
- SES MessageId를 NotificationId로 사용하지 않는다.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```java
|
|
package io.backend.skeleton.notification.email.ses;
|
|
|
|
import org.junit.jupiter.api.Test;
|
|
import static org.assertj.core.api.Assertions.*;
|
|
|
|
class SesNotificationProviderAdapterTest extends SesWireMockTestBase {
|
|
@Test
|
|
void messageIdIsAcceptedEvidenceOnly() {
|
|
stubSesSuccess("ses-message-1");
|
|
var result = adapter().submit(fixture().submission()).toCompletableFuture().join();
|
|
assertThat(result.providerRequestId()).contains("ses-message-1");
|
|
assertThat(result.evidenceLevel()).isEqualTo(EvidenceLevel.PROVIDER_ACCEPTED);
|
|
assertThat(result.deliveryOutcome()).isEqualTo(DeliveryOutcome.UNKNOWN);
|
|
}
|
|
|
|
@Test
|
|
void responseLossAfterServerAcceptsIsAmbiguous() {
|
|
stubAcceptThenReset();
|
|
var result = adapter().submit(fixture().submission()).toCompletableFuture().join();
|
|
assertThat(result.confirmation()).isEqualTo(AttemptConfirmation.AMBIGUOUS);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `./gradlew :modules:notification:notification-email-ses:test --tests "*.SesNotificationProviderAdapterTest"`
|
|
|
|
Expected: FAIL: SES adapter가 없다.
|
|
|
|
- [ ] **Step 3: Write the minimal implementation**
|
|
|
|
Files to implement:
|
|
- `SesNotificationProviderAdapter.java`
|
|
- `SesRequestMapper.java`
|
|
- `SesFailureClassifier.java`
|
|
- `SesProviderProperties.java`
|
|
|
|
```java
|
|
public CompletionStage<ProviderSubmissionResult> submit(ProviderSubmission submission) {
|
|
var request = mapper.map(submission);
|
|
return httpClient.send(request).handle((response, error) -> {
|
|
if (error != null) return classifier.fromTransport(error);
|
|
if (response.statusCode().is2xxSuccessful()) {
|
|
var messageId = response.body().messageId();
|
|
return ProviderSubmissionResult.accepted(
|
|
messageId, EvidenceLevel.PROVIDER_ACCEPTED,
|
|
response.executionEvidence());
|
|
}
|
|
return classifier.fromResponse(response);
|
|
});
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `./gradlew :modules:notification:notification-email-ses:test`
|
|
|
|
Expected: PASS: SES synchronous success와 response-loss ambiguity가 정확히 매핑된다.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add 'modules/notification/notification-email-ses/src/main/java/io/backend/skeleton/notification/email/ses/SesNotificationProviderAdapter.java' 'modules/notification/notification-email-ses/src/main/java/io/backend/skeleton/notification/email/ses/SesRequestMapper.java' 'modules/notification/notification-email-ses/src/main/java/io/backend/skeleton/notification/email/ses/SesFailureClassifier.java' 'modules/notification/notification-email-ses/src/main/java/io/backend/skeleton/notification/email/ses/SesProviderProperties.java' 'modules/notification/notification-email-ses/src/test/java/io/backend/skeleton/notification/email/ses/SesNotificationProviderAdapterTest.java'
|
|
git commit -m "feat(notification): add stable SES submission adapter"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 29: SES Event Ingestion·Projection·Suppression 구현
|
|
|
|
**Files:**
|
|
- Create: `modules/notification/notification-email-ses/src/main/java/io/backend/skeleton/notification/email/ses/SesCallbackAdapter.java`
|
|
- Create: `modules/notification/notification-email-ses/src/main/java/io/backend/skeleton/notification/email/ses/SesEventNormalizer.java`
|
|
- Create: `modules/notification/notification-email-ses/src/main/java/io/backend/skeleton/notification/email/ses/SesDeliveryProjector.java`
|
|
- Create: `modules/notification/notification-email-ses/src/main/java/io/backend/skeleton/notification/email/ses/SesSuppressionUpdater.java`
|
|
- Test: `modules/notification/notification-email-ses/src/test/java/io/backend/skeleton/notification/email/ses/SesEventProjectionTest.java`
|
|
|
|
**Interfaces:**
|
|
- Consumes: Tasks 14·22 ledger/callback core, Task 17 suppression.
|
|
- Produces: SES delivery, delay, bounce, complaint, reject, rendering failure event mapping.
|
|
|
|
**Implementation requirements:**
|
|
- Open/click event는 reliability delivery state와 분리한다.
|
|
- duplicate SES event는 ledger unique constraint로 no-op이다.
|
|
- soft bounce와 hard bounce를 같은 suppression reason으로 처리하지 않는다.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```java
|
|
package io.backend.skeleton.notification.email.ses;
|
|
|
|
import org.junit.jupiter.api.Test;
|
|
import static org.assertj.core.api.Assertions.*;
|
|
|
|
class SesEventProjectionTest {
|
|
@Test
|
|
void deliveryThenComplaintPreservesDeliveryAndAddsSuppression() {
|
|
ingest(fixture().deliveryEvent("m-1"));
|
|
ingest(fixture().complaintEvent("m-1"));
|
|
var projection = projection("m-1");
|
|
assertThat(projection.deliveryOutcome()).isEqualTo(DeliveryOutcome.DELIVERED);
|
|
assertThat(projection.suppressionFacts().complained()).isTrue();
|
|
assertThat(suppressionReason("m-1")).isEqualTo(SuppressionReason.COMPLAINT);
|
|
}
|
|
|
|
@Test
|
|
void hardBounceInvalidatesEmailAndSuppresses() {
|
|
ingest(fixture().hardBounce("m-2"));
|
|
assertThat(contactStatus("m-2")).isEqualTo(ContactPointStatus.INVALID);
|
|
assertThat(suppressionReason("m-2")).isEqualTo(SuppressionReason.HARD_BOUNCE);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `./gradlew :modules:notification:notification-email-ses:test --tests "*.SesEventProjectionTest"`
|
|
|
|
Expected: FAIL: SES event normalizer/projector가 없다.
|
|
|
|
- [ ] **Step 3: Write the minimal implementation**
|
|
|
|
Files to implement:
|
|
- `SesCallbackAdapter.java`
|
|
- `SesEventNormalizer.java`
|
|
- `SesDeliveryProjector.java`
|
|
- `SesSuppressionUpdater.java`
|
|
|
|
```java
|
|
public NormalizedProviderEvent normalize(SesEvent event) {
|
|
return switch (event.type()) {
|
|
case "Delivery" -> normalized("DELIVERY_CONFIRMED", DeliveryOutcome.DELIVERED,
|
|
EvidenceLevel.NETWORK_OR_CARRIER_ACCEPTED);
|
|
case "Bounce" -> normalizeBounce(event);
|
|
case "Complaint" -> normalizedFact("COMPLAINT");
|
|
case "DeliveryDelay" -> normalizedFact("DELIVERY_DELAYED");
|
|
case "Reject" -> normalizedFailure(FailureCategory.PERMANENT_PROVIDER);
|
|
case "RenderingFailure" -> normalizedFailure(FailureCategory.TEMPLATE_FAILURE);
|
|
default -> normalizedUnknown(event.type());
|
|
};
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `./gradlew :modules:notification:notification-email-ses:test`
|
|
|
|
Expected: PASS: SES delivery와 complaint/bounce facts가 독립적으로 반영된다.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add 'modules/notification/notification-email-ses/src/main/java/io/backend/skeleton/notification/email/ses/SesCallbackAdapter.java' 'modules/notification/notification-email-ses/src/main/java/io/backend/skeleton/notification/email/ses/SesEventNormalizer.java' 'modules/notification/notification-email-ses/src/main/java/io/backend/skeleton/notification/email/ses/SesDeliveryProjector.java' 'modules/notification/notification-email-ses/src/main/java/io/backend/skeleton/notification/email/ses/SesSuppressionUpdater.java' 'modules/notification/notification-email-ses/src/test/java/io/backend/skeleton/notification/email/ses/SesEventProjectionTest.java'
|
|
git commit -m "feat(notification): process SES delivery evidence and suppression"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 30: E.164 PhoneNumber와 GSM-7/UCS-2 Segment Estimator 구현
|
|
|
|
**Files:**
|
|
- Create: `modules/notification/notification-sms-api/src/main/java/io/backend/skeleton/notification/sms/E164PhoneNumberParser.java`
|
|
- Create: `modules/notification/notification-sms-api/src/main/java/io/backend/skeleton/notification/sms/GsmAlphabet.java`
|
|
- Create: `modules/notification/notification-sms-api/src/main/java/io/backend/skeleton/notification/sms/SmsSegmentEstimator.java`
|
|
- Test: `modules/notification/notification-sms-api/src/test/java/io/backend/skeleton/notification/sms/SmsSegmentEstimatorTest.java`
|
|
|
|
**Interfaces:**
|
|
- Consumes: Task 5 SmsEstimate·SmsEncoding and Task 6 PhoneNumber base type.
|
|
- Produces: Strict E.164 parser, GSM-7 extension-aware length, 160/153 and 70/67 segment calculation.
|
|
|
|
**Implementation requirements:**
|
|
- Java String length만으로 segment를 계산하지 않는다.
|
|
- GSM extension table 문자는 2 septet으로 계산한다.
|
|
- estimate는 비용 단가를 하드코딩하지 않고 segment count만 제공한다.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```java
|
|
package io.backend.skeleton.notification.sms;
|
|
|
|
import org.junit.jupiter.api.Test;
|
|
import static org.assertj.core.api.Assertions.*;
|
|
|
|
class SmsSegmentEstimatorTest {
|
|
@Test
|
|
void gsm7BoundaryUsesOneThenTwoSegments() {
|
|
assertThat(estimator().estimate("a".repeat(160)).segmentCount()).isEqualTo(1);
|
|
assertThat(estimator().estimate("a".repeat(161)).segmentCount()).isEqualTo(2);
|
|
}
|
|
|
|
@Test
|
|
void unicodeBoundaryUsesUcs2() {
|
|
assertThat(estimator().estimate("가".repeat(70)).segmentCount()).isEqualTo(1);
|
|
assertThat(estimator().estimate("가".repeat(71)).segmentCount()).isEqualTo(2);
|
|
assertThat(estimator().estimate("hello🙂").encoding()).isEqualTo(SmsEncoding.UCS_2);
|
|
}
|
|
|
|
@Test
|
|
void parsesE164AndRejectsLocalNumber() {
|
|
assertThat(parser().parse("+821012345678").e164()).isEqualTo("+821012345678");
|
|
assertThatThrownBy(() -> parser().parse("01012345678"))
|
|
.isInstanceOf(InvalidPhoneNumberException.class);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `./gradlew :modules:notification:notification-sms-api:test --tests "*.SmsSegmentEstimatorTest"`
|
|
|
|
Expected: FAIL: SMS parser와 estimator가 없다.
|
|
|
|
- [ ] **Step 3: Write the minimal implementation**
|
|
|
|
Files to implement:
|
|
- `E164PhoneNumberParser.java`
|
|
- `GsmAlphabet.java`
|
|
- `SmsSegmentEstimator.java`
|
|
|
|
```java
|
|
public SmsEstimate estimate(String text) {
|
|
var gsmUnits = GsmAlphabet.encodedSeptets(text);
|
|
if (gsmUnits.isPresent()) {
|
|
int units = gsmUnits.getAsInt();
|
|
int segments = units <= 160 ? 1 : divideCeiling(units, 153);
|
|
return new SmsEstimate(SmsEncoding.GSM_7, segments, units, segments > 3);
|
|
}
|
|
int units = text.codePoints().map(cp -> Character.charCount(cp)).sum();
|
|
int segments = units <= 70 ? 1 : divideCeiling(units, 67);
|
|
return new SmsEstimate(SmsEncoding.UCS_2, segments, units, segments > 3);
|
|
}
|
|
|
|
public PhoneNumber parse(String value) {
|
|
if (!value.matches("\+[1-9][0-9]{1,14}")) {
|
|
throw new InvalidPhoneNumberException("INVALID_E164");
|
|
}
|
|
return new PhoneNumber(value);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `./gradlew :modules:notification:notification-sms-api:test`
|
|
|
|
Expected: PASS: E.164와 SMS segment 경계가 정확히 계산된다.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add 'modules/notification/notification-sms-api/src/main/java/io/backend/skeleton/notification/sms/E164PhoneNumberParser.java' 'modules/notification/notification-sms-api/src/main/java/io/backend/skeleton/notification/sms/GsmAlphabet.java' 'modules/notification/notification-sms-api/src/main/java/io/backend/skeleton/notification/sms/SmsSegmentEstimator.java' 'modules/notification/notification-sms-api/src/test/java/io/backend/skeleton/notification/sms/SmsSegmentEstimatorTest.java'
|
|
git commit -m "feat(notification): add E164 and SMS segment estimation"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 31: Twilio SMS Submit Adapter 구현
|
|
|
|
**Files:**
|
|
- Create: `modules/notification/notification-sms-twilio/src/main/java/io/backend/skeleton/notification/sms/twilio/TwilioSmsProviderAdapter.java`
|
|
- Create: `modules/notification/notification-sms-twilio/src/main/java/io/backend/skeleton/notification/sms/twilio/TwilioRequestMapper.java`
|
|
- Create: `modules/notification/notification-sms-twilio/src/main/java/io/backend/skeleton/notification/sms/twilio/TwilioFailureClassifier.java`
|
|
- Create: `modules/notification/notification-sms-twilio/src/main/java/io/backend/skeleton/notification/sms/twilio/TwilioProviderProperties.java`
|
|
- Test: `modules/notification/notification-sms-twilio/src/test/java/io/backend/skeleton/notification/sms/twilio/TwilioSmsProviderAdapterTest.java`
|
|
|
|
**Interfaces:**
|
|
- Consumes: Task 10 provider SPI, Task 20 runtime, Task 30 E.164 and estimate, existing httpclient.
|
|
- Produces: Twilio API submit, accepted/queued evidence, invalid number, 429 and response-loss classification.
|
|
|
|
**Implementation requirements:**
|
|
- Twilio status callback URL은 profile에서 고정한다.
|
|
- phone number와 message body를 HTTP client log에 남기지 않는다.
|
|
- httpclient 자동 retry는 끄고 Notification retry policy가 소유한다.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```java
|
|
package io.backend.skeleton.notification.sms.twilio;
|
|
|
|
import org.junit.jupiter.api.Test;
|
|
import static org.assertj.core.api.Assertions.*;
|
|
|
|
class TwilioSmsProviderAdapterTest extends TwilioWireMockTestBase {
|
|
@Test
|
|
void acceptedStatusIsProviderAcceptedOnly() {
|
|
stubCreateMessage("SM1", "accepted");
|
|
var result = adapter().submit(fixture().submission()).toCompletableFuture().join();
|
|
assertThat(result.providerRequestId()).contains("SM1");
|
|
assertThat(result.evidenceLevel()).isEqualTo(EvidenceLevel.PROVIDER_ACCEPTED);
|
|
assertThat(result.deliveryOutcome()).isEqualTo(DeliveryOutcome.UNKNOWN);
|
|
}
|
|
|
|
@Test
|
|
void provider429IsThrottled() {
|
|
stubRateLimited(10);
|
|
var result = adapter().submit(fixture().submission()).toCompletableFuture().join();
|
|
assertThat(result.failure().orElseThrow().category())
|
|
.isEqualTo(FailureCategory.THROTTLED);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `./gradlew :modules:notification:notification-sms-twilio:test --tests "*.TwilioSmsProviderAdapterTest"`
|
|
|
|
Expected: FAIL: Twilio SMS adapter가 없다.
|
|
|
|
- [ ] **Step 3: Write the minimal implementation**
|
|
|
|
Files to implement:
|
|
- `TwilioSmsProviderAdapter.java`
|
|
- `TwilioRequestMapper.java`
|
|
- `TwilioFailureClassifier.java`
|
|
- `TwilioProviderProperties.java`
|
|
|
|
```java
|
|
public CompletionStage<ProviderSubmissionResult> submit(ProviderSubmission submission) {
|
|
var request = mapper.map(submission);
|
|
return twilioClient.createMessage(request).handle((response, error) -> {
|
|
if (error != null) return classifier.transport(error);
|
|
return switch (response.status()) {
|
|
case "accepted", "queued", "sending" ->
|
|
results.accepted(response.sid(), EvidenceLevel.PROVIDER_ACCEPTED, response.status());
|
|
case "failed" -> classifier.failed(response.errorCode());
|
|
default -> classifier.unexpected(response.status());
|
|
};
|
|
});
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `./gradlew :modules:notification:notification-sms-twilio:test`
|
|
|
|
Expected: PASS: Twilio accepted와 throttle/error가 stable result로 변환된다.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add 'modules/notification/notification-sms-twilio/src/main/java/io/backend/skeleton/notification/sms/twilio/TwilioSmsProviderAdapter.java' 'modules/notification/notification-sms-twilio/src/main/java/io/backend/skeleton/notification/sms/twilio/TwilioRequestMapper.java' 'modules/notification/notification-sms-twilio/src/main/java/io/backend/skeleton/notification/sms/twilio/TwilioFailureClassifier.java' 'modules/notification/notification-sms-twilio/src/main/java/io/backend/skeleton/notification/sms/twilio/TwilioProviderProperties.java' 'modules/notification/notification-sms-twilio/src/test/java/io/backend/skeleton/notification/sms/twilio/TwilioSmsProviderAdapterTest.java'
|
|
git commit -m "feat(notification): add stable Twilio SMS submission adapter"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 32: Twilio Callback·역순 Projection·Reconciliation 구현
|
|
|
|
**Files:**
|
|
- Create: `modules/notification/notification-sms-twilio/src/main/java/io/backend/skeleton/notification/sms/twilio/TwilioCallbackAdapter.java`
|
|
- Create: `modules/notification/notification-sms-twilio/src/main/java/io/backend/skeleton/notification/sms/twilio/TwilioStatusNormalizer.java`
|
|
- Create: `modules/notification/notification-sms-twilio/src/main/java/io/backend/skeleton/notification/sms/twilio/TwilioDeliveryProjector.java`
|
|
- Create: `modules/notification/notification-sms-twilio/src/main/java/io/backend/skeleton/notification/sms/twilio/TwilioReconciliationCapability.java`
|
|
- Test: `modules/notification/notification-sms-twilio/src/test/java/io/backend/skeleton/notification/sms/twilio/TwilioCallbackAndReconciliationTest.java`
|
|
|
|
**Interfaces:**
|
|
- Consumes: Tasks 22 callback core, 25 reconciliation, 31 Twilio provider profile.
|
|
- Produces: X-Twilio-Signature validation, status normalization, reverse-order merge, status polling.
|
|
|
|
**Implementation requirements:**
|
|
- callback receivedAt 순서가 아니라 event semantics를 사용한다.
|
|
- query polling은 provider QPS와 max reconciliation age를 준수한다.
|
|
- opt-out provider event를 internal suppression으로 연결한다.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```java
|
|
package io.backend.skeleton.notification.sms.twilio;
|
|
|
|
import org.junit.jupiter.api.Test;
|
|
import static org.assertj.core.api.Assertions.*;
|
|
|
|
class TwilioCallbackAndReconciliationTest extends TwilioCallbackTestBase {
|
|
@Test
|
|
void deliveredBeforeSentNeverDowngrades() {
|
|
ingest(callback("SM1", "delivered"));
|
|
ingest(callback("SM1", "sent"));
|
|
assertThat(projection("SM1").deliveryOutcome()).isEqualTo(DeliveryOutcome.DELIVERED);
|
|
}
|
|
|
|
@Test
|
|
void missingCallbackIsCorrectedByPolling() {
|
|
insertAcceptedAttempt("SM2");
|
|
stubStatusQuery("SM2", "undelivered");
|
|
reconcile("SM2");
|
|
assertThat(projection("SM2").deliveryOutcome()).isEqualTo(DeliveryOutcome.UNDELIVERED);
|
|
assertThat(lastEventSource()).isEqualTo(ProviderEventSource.RECONCILIATION);
|
|
}
|
|
|
|
@Test
|
|
void invalidSignatureIsRejected() {
|
|
assertThatThrownBy(() -> ingest(callbackWithInvalidSignature()))
|
|
.isInstanceOf(CallbackValidationException.class);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `./gradlew :modules:notification:notification-sms-twilio:test --tests "*.TwilioCallbackAndReconciliationTest"`
|
|
|
|
Expected: FAIL: Twilio callback adapter/projector/reconcile가 없다.
|
|
|
|
- [ ] **Step 3: Write the minimal implementation**
|
|
|
|
Files to implement:
|
|
- `TwilioCallbackAdapter.java`
|
|
- `TwilioStatusNormalizer.java`
|
|
- `TwilioDeliveryProjector.java`
|
|
- `TwilioReconciliationCapability.java`
|
|
|
|
```java
|
|
public NormalizedProviderEvent normalize(TwilioStatusCallback callback) {
|
|
return switch (callback.messageStatus()) {
|
|
case "accepted", "queued", "sending" -> accepted(callback);
|
|
case "sent" -> sent(callback, EvidenceLevel.NETWORK_OR_CARRIER_ACCEPTED);
|
|
case "delivered" -> delivered(callback, EvidenceLevel.DEVICE_DELIVERED);
|
|
case "undelivered" -> undelivered(callback);
|
|
case "failed" -> failed(callback);
|
|
default -> unknown(callback);
|
|
};
|
|
}
|
|
|
|
// Projector transition table ignores sent after delivered and retains terminal facts.
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `./gradlew :modules:notification:notification-sms-twilio:test`
|
|
|
|
Expected: PASS: signature, reverse-order callback, missing callback reconciliation이 통과한다.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add 'modules/notification/notification-sms-twilio/src/main/java/io/backend/skeleton/notification/sms/twilio/TwilioCallbackAdapter.java' 'modules/notification/notification-sms-twilio/src/main/java/io/backend/skeleton/notification/sms/twilio/TwilioStatusNormalizer.java' 'modules/notification/notification-sms-twilio/src/main/java/io/backend/skeleton/notification/sms/twilio/TwilioDeliveryProjector.java' 'modules/notification/notification-sms-twilio/src/main/java/io/backend/skeleton/notification/sms/twilio/TwilioReconciliationCapability.java' 'modules/notification/notification-sms-twilio/src/test/java/io/backend/skeleton/notification/sms/twilio/TwilioCallbackAndReconciliationTest.java'
|
|
git commit -m "feat(notification): add Twilio status callback and reconciliation"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 33: Mobile Push 공통 API와 Application Receipt 구현
|
|
|
|
**Files:**
|
|
- Create: `modules/notification/notification-push-api/src/main/java/io/backend/skeleton/notification/push/ApplicationReceipt.java`
|
|
- Create: `modules/notification/notification-push-api/src/main/java/io/backend/skeleton/notification/push/ApplicationReceiptService.java`
|
|
- Create: `modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/ApplicationReceiptServiceImpl.java`
|
|
- Test: `modules/notification/notification-dispatch-runtime/src/test/java/io/backend/skeleton/notification/dispatch/ApplicationReceiptServiceTest.java`
|
|
|
|
**Interfaces:**
|
|
- Consumes: Task 3 PushPresentation, Task 5 MobilePushNotification, Task 6 push targets, Task 14 event ledger.
|
|
- Produces: Typed push API, authenticated displayed/read receipt, receipt dedup and evidence promotion.
|
|
|
|
**Implementation requirements:**
|
|
- Provider acceptance 없이 앱 receipt만으로 임의 Attempt를 생성하지 않는다.
|
|
- receipt ID, attempt ID, authenticated user/app binding을 검증한다.
|
|
- client clock은 참고값이고 server receivedAt을 감사 기준으로 보존한다.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```java
|
|
package io.backend.skeleton.notification.dispatch;
|
|
|
|
import org.junit.jupiter.api.Test;
|
|
import static org.assertj.core.api.Assertions.*;
|
|
|
|
class ApplicationReceiptServiceTest extends PostgreSqlNotificationTest {
|
|
@Test
|
|
void readReceiptPromotesEvidenceAndIsIdempotent() {
|
|
var receipt = fixture().readReceipt("receipt-1");
|
|
service().record(receipt, fixture().authenticatedApp());
|
|
service().record(receipt, fixture().authenticatedApp());
|
|
assertThat(projection(receipt.attemptId()).evidenceLevel()).isEqualTo(EvidenceLevel.USER_READ);
|
|
assertThat(receiptEventCount("receipt-1")).isEqualTo(1);
|
|
}
|
|
|
|
@Test
|
|
void receiptForAnotherUserIsRejected() {
|
|
assertThatThrownBy(() -> service().record(
|
|
fixture().readReceiptForUser("user-b"), fixture().authenticatedUser("user-a")))
|
|
.isInstanceOf(ReceiptAuthorizationException.class);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `./gradlew :modules:notification:notification-dispatch-runtime:test --tests "*.ApplicationReceiptServiceTest"`
|
|
|
|
Expected: FAIL: app receipt API와 implementation이 없다.
|
|
|
|
- [ ] **Step 3: Write the minimal implementation**
|
|
|
|
Files to implement:
|
|
- `ApplicationReceipt.java`
|
|
- `ApplicationReceiptService.java`
|
|
- `ApplicationReceiptServiceImpl.java`
|
|
|
|
```java
|
|
public interface ApplicationReceiptService {
|
|
ReceiptResult record(ApplicationReceipt receipt, ApplicationIdentity identity);
|
|
}
|
|
|
|
@Transactional
|
|
public ReceiptResult record(ApplicationReceipt receipt, ApplicationIdentity identity) {
|
|
authorization.verify(identity, receipt);
|
|
var append = ledger.append(receiptEventFactory.from(receipt));
|
|
if (append.created()) projector.project(append.eventId());
|
|
return new ReceiptResult(append.created(), receipt.attemptId());
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `./gradlew :modules:notification:notification-dispatch-runtime:test`
|
|
|
|
Expected: PASS: authenticated app receipt가 idempotent하게 evidence를 승격한다.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add 'modules/notification/notification-push-api/src/main/java/io/backend/skeleton/notification/push/ApplicationReceipt.java' 'modules/notification/notification-push-api/src/main/java/io/backend/skeleton/notification/push/ApplicationReceiptService.java' 'modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/ApplicationReceiptServiceImpl.java' 'modules/notification/notification-dispatch-runtime/src/test/java/io/backend/skeleton/notification/dispatch/ApplicationReceiptServiceTest.java'
|
|
git commit -m "feat(notification): add mobile push and app receipt contracts"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 34: FCM FID·Legacy Target Mapper와 Recipient별 Batch Adapter 구현
|
|
|
|
**Files:**
|
|
- Create: `modules/notification/notification-push-fcm/src/main/java/io/backend/skeleton/notification/push/fcm/FcmNotificationProviderAdapter.java`
|
|
- Create: `modules/notification/notification-push-fcm/src/main/java/io/backend/skeleton/notification/push/fcm/FcmTargetMapper.java`
|
|
- Create: `modules/notification/notification-push-fcm/src/main/java/io/backend/skeleton/notification/push/fcm/FcmBatchCoordinator.java`
|
|
- Create: `modules/notification/notification-push-fcm/src/main/java/io/backend/skeleton/notification/push/fcm/FcmProviderProperties.java`
|
|
- Test: `modules/notification/notification-push-fcm/src/test/java/io/backend/skeleton/notification/push/fcm/FcmBatchAdapterTest.java`
|
|
|
|
**Interfaces:**
|
|
- Consumes: Tasks 6 FID/legacy types, 10 provider SPI, 33 push API.
|
|
- Produces: FID-primary mapping, legacy compatibility, max-500 batch, input-index partial result mapping.
|
|
|
|
**Implementation requirements:**
|
|
- Batch transport 호출 하나를 하나의 RecipientDelivery로 축소하지 않는다.
|
|
- FCM success evidence는 PROVIDER_ACCEPTED까지만 설정한다.
|
|
- topic/condition은 N3 experimental interface에 분리한다.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```java
|
|
package io.backend.skeleton.notification.push.fcm;
|
|
|
|
import org.junit.jupiter.api.Test;
|
|
import static org.assertj.core.api.Assertions.*;
|
|
|
|
class FcmBatchAdapterTest {
|
|
@Test
|
|
void mapsPartialBatchResultToEachRecipientAttempt() {
|
|
var submissions = fixture().fiveSubmissions();
|
|
var gateway = fixture().gatewayWithResults(true, false, true, false, true);
|
|
var results = coordinator(gateway).submit(submissions).toCompletableFuture().join();
|
|
assertThat(results).hasSize(5);
|
|
assertThat(results.get(0).confirmation()).isEqualTo(AttemptConfirmation.CONFIRMED);
|
|
assertThat(results.get(1).confirmation()).isEqualTo(AttemptConfirmation.REJECTED);
|
|
}
|
|
|
|
@Test
|
|
void rejectsBatchAbove500() {
|
|
assertThatThrownBy(() -> coordinator(fixture().gateway()).submit(fixture().submissions(501)))
|
|
.isInstanceOf(ProviderPayloadLimitException.class);
|
|
}
|
|
|
|
@Test
|
|
void fidAndLegacyTokenUseDistinctWireTargetKinds() {
|
|
assertThat(mapper().map(new FcmInstallationId("fid-1")).kind()).isEqualTo("FID");
|
|
assertThat(mapper().map(new LegacyFcmRegistrationToken("token-1")).kind()).isEqualTo("LEGACY_TOKEN");
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `./gradlew :modules:notification:notification-push-fcm:test --tests "*.FcmBatchAdapterTest"`
|
|
|
|
Expected: FAIL: FCM adapter/batch coordinator가 없다.
|
|
|
|
- [ ] **Step 3: Write the minimal implementation**
|
|
|
|
Files to implement:
|
|
- `FcmNotificationProviderAdapter.java`
|
|
- `FcmTargetMapper.java`
|
|
- `FcmBatchCoordinator.java`
|
|
- `FcmProviderProperties.java`
|
|
|
|
```java
|
|
public CompletionStage<java.util.List<ProviderSubmissionResult>> submitBatch(
|
|
java.util.List<ProviderSubmission> submissions) {
|
|
if (submissions.size() > properties.maxBatchSize()) {
|
|
throw new ProviderPayloadLimitException("FCM_BATCH_MAX_500");
|
|
}
|
|
var request = mapper.mapBatch(submissions);
|
|
return gateway.sendBatch(request).thenApply(response ->
|
|
java.util.stream.IntStream.range(0, submissions.size())
|
|
.mapToObj(i -> resultMapper.map(submissions.get(i), response.result(i)))
|
|
.toList());
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `./gradlew :modules:notification:notification-push-fcm:test`
|
|
|
|
Expected: PASS: FID/legacy target와 recipient별 partial batch result가 검증된다.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add 'modules/notification/notification-push-fcm/src/main/java/io/backend/skeleton/notification/push/fcm/FcmNotificationProviderAdapter.java' 'modules/notification/notification-push-fcm/src/main/java/io/backend/skeleton/notification/push/fcm/FcmTargetMapper.java' 'modules/notification/notification-push-fcm/src/main/java/io/backend/skeleton/notification/push/fcm/FcmBatchCoordinator.java' 'modules/notification/notification-push-fcm/src/main/java/io/backend/skeleton/notification/push/fcm/FcmProviderProperties.java' 'modules/notification/notification-push-fcm/src/test/java/io/backend/skeleton/notification/push/fcm/FcmBatchAdapterTest.java'
|
|
git commit -m "feat(notification): add FCM FID-first batch adapter"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 35: FCM 오류 분류·Target Invalidation·TTL/Collapse 구현
|
|
|
|
**Files:**
|
|
- Create: `modules/notification/notification-push-fcm/src/main/java/io/backend/skeleton/notification/push/fcm/FcmFailureClassifier.java`
|
|
- Create: `modules/notification/notification-push-fcm/src/main/java/io/backend/skeleton/notification/push/fcm/FcmContactPointUpdater.java`
|
|
- Create: `modules/notification/notification-push-fcm/src/main/java/io/backend/skeleton/notification/push/fcm/FcmMessageMapper.java`
|
|
- Test: `modules/notification/notification-push-fcm/src/test/java/io/backend/skeleton/notification/push/fcm/FcmFailureAndLifecycleTest.java`
|
|
|
|
**Interfaces:**
|
|
- Consumes: Task 17 policy, Task 34 FCM adapter.
|
|
- Produces: UNREGISTERED invalidation, quota/unavailable retry mapping, provider TTL and collapse mapping.
|
|
|
|
**Implementation requirements:**
|
|
- UNREGISTERED를 transient로 retry하지 않는다.
|
|
- credential failure는 Provider runtime auth failure로 승격한다.
|
|
- FCM delivery order를 보장한다고 문서화하지 않는다.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```java
|
|
package io.backend.skeleton.notification.push.fcm;
|
|
|
|
import org.junit.jupiter.api.Test;
|
|
import java.time.*;
|
|
import static org.assertj.core.api.Assertions.*;
|
|
|
|
class FcmFailureAndLifecycleTest extends FcmTestBase {
|
|
@Test
|
|
void unregisteredInvalidatesContactAndDoesNotRetry() {
|
|
var result = classifier().classify(fixture().error("UNREGISTERED"));
|
|
updater().apply(fixture().contactId(), result);
|
|
assertThat(result.failure().orElseThrow().category()).isEqualTo(FailureCategory.INVALID_RECIPIENT);
|
|
assertThat(contactStatus()).isEqualTo(ContactPointStatus.INVALID);
|
|
assertThat(retryPolicy().decide(fixture().context(result)))
|
|
.isInstanceOf(RetryDecision.Fallback.class);
|
|
}
|
|
|
|
@Test
|
|
void ttlIsCappedByExpiry() {
|
|
var now = Instant.parse("2026-08-10T00:00:00Z");
|
|
var message = mapper(now).map(fixture().submissionExpiringAt(now.plusSeconds(90)));
|
|
assertThat(message.ttl()).isEqualTo(Duration.ofSeconds(90));
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `./gradlew :modules:notification:notification-push-fcm:test --tests "*.FcmFailureAndLifecycleTest"`
|
|
|
|
Expected: FAIL: FCM failure classifier와 lifecycle updater가 없다.
|
|
|
|
- [ ] **Step 3: Write the minimal implementation**
|
|
|
|
Files to implement:
|
|
- `FcmFailureClassifier.java`
|
|
- `FcmContactPointUpdater.java`
|
|
- `FcmMessageMapper.java`
|
|
|
|
```java
|
|
public ProviderSubmissionResult classify(FcmError error) {
|
|
return switch (error.code()) {
|
|
case "UNREGISTERED" -> rejected(FailureCategory.INVALID_RECIPIENT, false);
|
|
case "QUOTA_EXCEEDED" -> rejected(FailureCategory.THROTTLED, true);
|
|
case "UNAVAILABLE" -> rejected(FailureCategory.TRANSIENT_PROVIDER, true);
|
|
case "INVALID_ARGUMENT" -> rejected(FailureCategory.INVALID_PAYLOAD, false);
|
|
case "THIRD_PARTY_AUTH_ERROR" -> rejected(FailureCategory.AUTHENTICATION, false);
|
|
default -> rejected(FailureCategory.PERMANENT_PROVIDER, false);
|
|
};
|
|
}
|
|
|
|
// Message mapper computes TTL = min(expiresAt-now, provider max TTL) and maps
|
|
// collapse key only when CollapseSpec is present.
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `./gradlew :modules:notification:notification-push-fcm:test`
|
|
|
|
Expected: PASS: FCM target invalidation, retry classification, TTL/collapse mapping이 통과한다.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add 'modules/notification/notification-push-fcm/src/main/java/io/backend/skeleton/notification/push/fcm/FcmFailureClassifier.java' 'modules/notification/notification-push-fcm/src/main/java/io/backend/skeleton/notification/push/fcm/FcmContactPointUpdater.java' 'modules/notification/notification-push-fcm/src/main/java/io/backend/skeleton/notification/push/fcm/FcmMessageMapper.java' 'modules/notification/notification-push-fcm/src/test/java/io/backend/skeleton/notification/push/fcm/FcmFailureAndLifecycleTest.java'
|
|
git commit -m "feat(notification): add FCM lifecycle and failure semantics"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 36: APNs HTTP/2 Adapter와 Environment·Topic Guard 구현
|
|
|
|
**Files:**
|
|
- Create: `modules/notification/notification-push-apns/src/main/java/io/backend/skeleton/notification/push/apns/ApnsNotificationProviderAdapter.java`
|
|
- Create: `modules/notification/notification-push-apns/src/main/java/io/backend/skeleton/notification/push/apns/ApnsRequestMapper.java`
|
|
- Create: `modules/notification/notification-push-apns/src/main/java/io/backend/skeleton/notification/push/apns/ApnsFailureClassifier.java`
|
|
- Create: `modules/notification/notification-push-apns/src/main/java/io/backend/skeleton/notification/push/apns/ApnsProviderProperties.java`
|
|
- Test: `modules/notification/notification-push-apns/src/test/java/io/backend/skeleton/notification/push/apns/ApnsNotificationProviderAdapterTest.java`
|
|
|
|
**Interfaces:**
|
|
- Consumes: Task 6 APNs token/environment, Task 10 provider SPI, existing httpclient HTTP/2 profile.
|
|
- Produces: APNs headers, acceptance-only evidence, environment/topic mismatch, invalid token classification.
|
|
|
|
**Implementation requirements:**
|
|
- APNs 2xx를 delivered로 매핑하지 않는다.
|
|
- push type은 allowlist된 typed option만 허용한다.
|
|
- sandbox/production Contact Point namespace를 분리한다.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```java
|
|
package io.backend.skeleton.notification.push.apns;
|
|
|
|
import org.junit.jupiter.api.Test;
|
|
import static org.assertj.core.api.Assertions.*;
|
|
|
|
class ApnsNotificationProviderAdapterTest extends ApnsHttp2TestBase {
|
|
@Test
|
|
void http200IsProviderAcceptedNotDelivered() {
|
|
stubApnsSuccess("apns-request-1");
|
|
var result = adapter().submit(fixture().productionSubmission()).toCompletableFuture().join();
|
|
assertThat(result.providerRequestId()).contains("apns-request-1");
|
|
assertThat(result.evidenceLevel()).isEqualTo(EvidenceLevel.PROVIDER_ACCEPTED);
|
|
assertThat(result.deliveryOutcome()).isEqualTo(DeliveryOutcome.UNKNOWN);
|
|
}
|
|
|
|
@Test
|
|
void sandboxTokenCannotUseProductionProfile() {
|
|
assertThatThrownBy(() -> adapter().submit(fixture().sandboxTokenOnProduction()))
|
|
.isInstanceOf(ProviderConfigurationException.class);
|
|
}
|
|
|
|
@Test
|
|
void invalidTokenInvalidatesContactPoint() {
|
|
stubApnsError(410, "Unregistered");
|
|
var result = adapter().submit(fixture().productionSubmission()).toCompletableFuture().join();
|
|
assertThat(result.failure().orElseThrow().category()).isEqualTo(FailureCategory.INVALID_RECIPIENT);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `./gradlew :modules:notification:notification-push-apns:test --tests "*.ApnsNotificationProviderAdapterTest"`
|
|
|
|
Expected: FAIL: APNs adapter가 없다.
|
|
|
|
- [ ] **Step 3: Write the minimal implementation**
|
|
|
|
Files to implement:
|
|
- `ApnsNotificationProviderAdapter.java`
|
|
- `ApnsRequestMapper.java`
|
|
- `ApnsFailureClassifier.java`
|
|
- `ApnsProviderProperties.java`
|
|
|
|
```java
|
|
public ApnsRequest map(ProviderSubmission submission) {
|
|
var target = requireApnsTarget(submission.contactPoint());
|
|
if (target.environment() != properties.environment()) {
|
|
throw new ProviderConfigurationException("APNS_ENVIRONMENT_MISMATCH");
|
|
}
|
|
return new ApnsRequest(
|
|
target.value(),
|
|
properties.topic(),
|
|
approvedPushType(submission),
|
|
expirationEpoch(submission.expiresAt()),
|
|
approvedPriority(submission),
|
|
collapseId(submission),
|
|
payload(submission));
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `./gradlew :modules:notification:notification-push-apns:test`
|
|
|
|
Expected: PASS: APNs 2xx, environment, invalid token, header mapping이 검증된다.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add 'modules/notification/notification-push-apns/src/main/java/io/backend/skeleton/notification/push/apns/ApnsNotificationProviderAdapter.java' 'modules/notification/notification-push-apns/src/main/java/io/backend/skeleton/notification/push/apns/ApnsRequestMapper.java' 'modules/notification/notification-push-apns/src/main/java/io/backend/skeleton/notification/push/apns/ApnsFailureClassifier.java' 'modules/notification/notification-push-apns/src/main/java/io/backend/skeleton/notification/push/apns/ApnsProviderProperties.java' 'modules/notification/notification-push-apns/src/test/java/io/backend/skeleton/notification/push/apns/ApnsNotificationProviderAdapterTest.java'
|
|
git commit -m "feat(notification): add stable APNs adapter"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 37: Web Push RFC 8291 Encryption과 VAPID 구현
|
|
|
|
**Files:**
|
|
- Create: `modules/notification/notification-webpush/src/main/java/io/backend/skeleton/notification/webpush/WebPushSubscription.java`
|
|
- Create: `modules/notification/notification-webpush/src/main/java/io/backend/skeleton/notification/webpush/WebPushPayloadEncryptor.java`
|
|
- Create: `modules/notification/notification-webpush/src/main/java/io/backend/skeleton/notification/webpush/Rfc8291Aes128GcmEncryptor.java`
|
|
- Create: `modules/notification/notification-webpush/src/main/java/io/backend/skeleton/notification/webpush/VapidJwtSigner.java`
|
|
- Create: `modules/notification/notification-webpush/src/main/java/io/backend/skeleton/notification/webpush/VapidKeyRegistry.java`
|
|
- Test: `modules/notification/notification-webpush/src/test/java/io/backend/skeleton/notification/webpush/WebPushCryptoTest.java`
|
|
|
|
**Interfaces:**
|
|
- Consumes: Task 7 secret provider, Task 10 provider SPI.
|
|
- Produces: Encrypted subscription, RFC 8291 AES128GCM payload, RFC 8292 ES256 VAPID JWT.
|
|
|
|
**Implementation requirements:**
|
|
- Web Push endpoint와 key material을 log에 출력하지 않는다.
|
|
- VAPID private key는 SecretMaterialProvider에서만 획득한다.
|
|
- VAPID key rotation은 subscription migration이 필요하다는 상태를 노출한다.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```java
|
|
package io.backend.skeleton.notification.webpush;
|
|
|
|
import org.junit.jupiter.api.Test;
|
|
import static org.assertj.core.api.Assertions.*;
|
|
|
|
class WebPushCryptoTest {
|
|
@Test
|
|
void encryptsPayloadUsingSubscriptionKeys() {
|
|
var encrypted = encryptor().encrypt(
|
|
fixture().subscription(), "hello".getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
|
assertThat(encrypted.contentEncoding()).isEqualTo("aes128gcm");
|
|
assertThat(encrypted.body()).doesNotContainSequence("hello".getBytes());
|
|
assertThat(fixture().decrypt(encrypted)).isEqualTo("hello".getBytes());
|
|
}
|
|
|
|
@Test
|
|
void vapidAudienceUsesEndpointOrigin() {
|
|
var jwt = signer().sign(fixture().subscription(), fixture().key());
|
|
assertThat(fixture().claims(jwt).audience()).isEqualTo("https://push.example.com");
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `./gradlew :modules:notification:notification-webpush:test --tests "*.WebPushCryptoTest"`
|
|
|
|
Expected: FAIL: Web Push crypto와 VAPID가 없다.
|
|
|
|
- [ ] **Step 3: Write the minimal implementation**
|
|
|
|
Files to implement:
|
|
- `WebPushSubscription.java`
|
|
- `WebPushPayloadEncryptor.java`
|
|
- `Rfc8291Aes128GcmEncryptor.java`
|
|
- `VapidJwtSigner.java`
|
|
- `VapidKeyRegistry.java`
|
|
|
|
```java
|
|
public record WebPushSubscription(
|
|
ContactPointId id,
|
|
EncryptedValue endpoint,
|
|
EncryptedValue p256dh,
|
|
EncryptedValue authSecret,
|
|
String vapidKeyId
|
|
) {}
|
|
|
|
public EncryptedWebPushPayload encrypt(WebPushKeyMaterial subscription, byte[] plaintext) {
|
|
// RFC 8291: P-256 ECDH, auth secret, HKDF, random salt, aes128gcm record.
|
|
var sharedSecret = ecdh.derive(ephemeralKeyPair(), subscription.p256dh());
|
|
var keyAndNonce = hkdf.derive(sharedSecret, subscription.authSecret(), randomSalt());
|
|
return aes128gcm.encrypt(plaintext, keyAndNonce);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `./gradlew :modules:notification:notification-webpush:test`
|
|
|
|
Expected: PASS: RFC encryption round-trip과 VAPID audience가 검증된다.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add 'modules/notification/notification-webpush/src/main/java/io/backend/skeleton/notification/webpush/WebPushSubscription.java' 'modules/notification/notification-webpush/src/main/java/io/backend/skeleton/notification/webpush/WebPushPayloadEncryptor.java' 'modules/notification/notification-webpush/src/main/java/io/backend/skeleton/notification/webpush/Rfc8291Aes128GcmEncryptor.java' 'modules/notification/notification-webpush/src/main/java/io/backend/skeleton/notification/webpush/VapidJwtSigner.java' 'modules/notification/notification-webpush/src/main/java/io/backend/skeleton/notification/webpush/VapidKeyRegistry.java' 'modules/notification/notification-webpush/src/test/java/io/backend/skeleton/notification/webpush/WebPushCryptoTest.java'
|
|
git commit -m "feat(notification): add Web Push encryption and VAPID"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 38: Web Push RFC 8030 Transport·TTL·Subscription Invalidation 구현
|
|
|
|
**Files:**
|
|
- Create: `modules/notification/notification-webpush/src/main/java/io/backend/skeleton/notification/webpush/WebPushNotificationProviderAdapter.java`
|
|
- Create: `modules/notification/notification-webpush/src/main/java/io/backend/skeleton/notification/webpush/WebPushRequestMapper.java`
|
|
- Create: `modules/notification/notification-webpush/src/main/java/io/backend/skeleton/notification/webpush/WebPushFailureClassifier.java`
|
|
- Create: `modules/notification/notification-webpush/src/main/java/io/backend/skeleton/notification/webpush/WebPushReceiptCapability.java`
|
|
- Test: `modules/notification/notification-webpush/src/test/java/io/backend/skeleton/notification/webpush/WebPushProviderAdapterTest.java`
|
|
|
|
**Interfaces:**
|
|
- Consumes: Task 37 crypto/VAPID, existing HTTP Client Dynamic/Trusted policy.
|
|
- Produces: TTL-required send, urgency/topic, 201 acceptance, 404/410 invalidation, optional receipt capability.
|
|
|
|
**Implementation requirements:**
|
|
- Web Push endpoint를 arbitrary dynamic URL로 일반 공개하지 않는다.
|
|
- HTTP redirect를 따르지 않는다.
|
|
- receipt 지원 여부를 ProviderCapabilities로 확인한다.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```java
|
|
package io.backend.skeleton.notification.webpush;
|
|
|
|
import org.junit.jupiter.api.Test;
|
|
import static org.assertj.core.api.Assertions.*;
|
|
|
|
class WebPushProviderAdapterTest extends WebPushWireMockTestBase {
|
|
@Test
|
|
void ttlHeaderIsRequiredAndAcceptanceIsNotDelivery() {
|
|
stubPushService(201);
|
|
var result = adapter().submit(fixture().submissionWithTtl(60)).toCompletableFuture().join();
|
|
verifyHeader("TTL", "60");
|
|
assertThat(result.evidenceLevel()).isEqualTo(EvidenceLevel.PROVIDER_ACCEPTED);
|
|
assertThat(result.deliveryOutcome()).isEqualTo(DeliveryOutcome.UNKNOWN);
|
|
}
|
|
|
|
@Test
|
|
void expiredSubscriptionIsInvalidated() {
|
|
stubPushService(404);
|
|
var result = adapter().submit(fixture().submissionWithTtl(60)).toCompletableFuture().join();
|
|
assertThat(result.failure().orElseThrow().category()).isEqualTo(FailureCategory.INVALID_RECIPIENT);
|
|
}
|
|
|
|
@Test
|
|
void missingExpiryCannotCreateWebPushAttempt() {
|
|
assertThatThrownBy(() -> adapter().submit(fixture().submissionWithoutExpiry()))
|
|
.isInstanceOf(ProviderConfigurationException.class);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `./gradlew :modules:notification:notification-webpush:test --tests "*.WebPushProviderAdapterTest"`
|
|
|
|
Expected: FAIL: Web Push transport adapter가 없다.
|
|
|
|
- [ ] **Step 3: Write the minimal implementation**
|
|
|
|
Files to implement:
|
|
- `WebPushNotificationProviderAdapter.java`
|
|
- `WebPushRequestMapper.java`
|
|
- `WebPushFailureClassifier.java`
|
|
- `WebPushReceiptCapability.java`
|
|
|
|
```java
|
|
public WebPushRequest map(ProviderSubmission submission) {
|
|
var ttl = java.time.Duration.between(clock.instant(), submission.expiresAt());
|
|
if (ttl.isNegative() || ttl.isZero()) throw new NotificationExpiredException("WEBPUSH_EXPIRED");
|
|
var encrypted = encryptor.encrypt(subscriptionKeys(submission), payload(submission));
|
|
return new WebPushRequest(
|
|
endpoint(submission), ttl.toSeconds(), urgency(submission), topic(submission),
|
|
vapidSigner.authorization(endpoint(submission)), encrypted);
|
|
}
|
|
|
|
// 201 = PROVIDER_ACCEPTED, 404 and provider-documented 410 = INVALID_RECIPIENT.
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `./gradlew :modules:notification:notification-webpush:test`
|
|
|
|
Expected: PASS: TTL, acceptance evidence, subscription invalidation이 검증된다.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add 'modules/notification/notification-webpush/src/main/java/io/backend/skeleton/notification/webpush/WebPushNotificationProviderAdapter.java' 'modules/notification/notification-webpush/src/main/java/io/backend/skeleton/notification/webpush/WebPushRequestMapper.java' 'modules/notification/notification-webpush/src/main/java/io/backend/skeleton/notification/webpush/WebPushFailureClassifier.java' 'modules/notification/notification-webpush/src/main/java/io/backend/skeleton/notification/webpush/WebPushReceiptCapability.java' 'modules/notification/notification-webpush/src/test/java/io/backend/skeleton/notification/webpush/WebPushProviderAdapterTest.java'
|
|
git commit -m "feat(notification): add stable Web Push transport"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 39: In-App Inbox API·JPA Persistence·Cursor Pagination 구현
|
|
|
|
**Files:**
|
|
- Create: `modules/notification/notification-inbox-api/src/main/java/io/backend/skeleton/notification/inbox/NotificationInbox.java`
|
|
- Create: `modules/notification/notification-inbox-api/src/main/java/io/backend/skeleton/notification/inbox/InboxItem.java`
|
|
- Create: `modules/notification/notification-inbox-api/src/main/java/io/backend/skeleton/notification/inbox/InboxCursor.java`
|
|
- Create: `modules/notification/notification-inbox-api/src/main/java/io/backend/skeleton/notification/inbox/InboxItemId.java`
|
|
- Create: `modules/notification/notification-inbox-api/src/main/java/io/backend/skeleton/notification/inbox/InboxPrincipal.java`
|
|
- Create: `modules/notification/notification-inbox-api/src/main/java/io/backend/skeleton/notification/inbox/InboxPage.java`
|
|
- Create: `modules/notification/notification-inbox-api/src/main/java/io/backend/skeleton/notification/inbox/InboxQuery.java`
|
|
- Create: `modules/notification/notification-inbox-api/src/main/java/io/backend/skeleton/notification/inbox/InboxMutationResult.java`
|
|
- Create: `modules/notification/notification-inbox-jpa/src/main/java/io/backend/skeleton/notification/inbox/jpa/InboxItemEntity.java`
|
|
- Create: `modules/notification/notification-inbox-jpa/src/main/java/io/backend/skeleton/notification/inbox/jpa/JpaNotificationInbox.java`
|
|
- Create: `modules/notification/notification-inbox-jpa/src/main/java/io/backend/skeleton/notification/inbox/jpa/InboxUnreadCounter.java`
|
|
- Test: `modules/notification/notification-inbox-jpa/src/test/java/io/backend/skeleton/notification/inbox/jpa/JpaNotificationInboxTest.java`
|
|
|
|
**Interfaces:**
|
|
- Consumes: Task 12 inbox schema, Task 3 InAppContent.
|
|
- Produces: Tenant/user-bound cursor pagination, idempotent seen/read/archive, consistent unread count.
|
|
|
|
**Implementation requirements:**
|
|
- WebSocket availability를 Inbox transaction에 포함하지 않는다.
|
|
- unread count cache를 source of truth로 사용하지 않는다.
|
|
- bulk mark-read는 bounded batch와 cursor cutoff를 사용한다.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```java
|
|
package io.backend.skeleton.notification.inbox.jpa;
|
|
|
|
import org.junit.jupiter.api.Test;
|
|
import static org.assertj.core.api.Assertions.*;
|
|
|
|
class JpaNotificationInboxTest extends PostgreSqlNotificationTest {
|
|
@Test
|
|
void cursorPaginationIsStableWithSameTimestamp() {
|
|
insertInboxItemsWithSameTimestamp(25);
|
|
var first = inbox().list(query().limit(10));
|
|
var second = inbox().list(query().after(first.nextCursor()).limit(10));
|
|
assertThat(first.items()).doesNotContainAnyElementsOf(second.items());
|
|
assertThat(first.items()).hasSize(10);
|
|
assertThat(second.items()).hasSize(10);
|
|
}
|
|
|
|
@Test
|
|
void concurrentMarkReadIsIdempotentAndUnreadCountStaysCorrect() {
|
|
var id = insertUnreadItem();
|
|
runConcurrently(20, () -> inbox().markRead(id, principal()));
|
|
assertThat(inbox().unreadCount(principal())).isZero();
|
|
assertThat(readAuditCount(id)).isEqualTo(1);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `./gradlew :modules:notification:notification-inbox-jpa:test --tests "*.JpaNotificationInboxTest"`
|
|
|
|
Expected: FAIL: Inbox API/JPA implementation이 없다.
|
|
|
|
- [ ] **Step 3: Write the minimal implementation**
|
|
|
|
Files to implement:
|
|
- `NotificationInbox.java`
|
|
- `InboxItem.java`
|
|
- `InboxCursor.java`
|
|
- `InboxItemId.java`
|
|
- `InboxPrincipal.java`
|
|
- `InboxPage.java`
|
|
- `InboxQuery.java`
|
|
- `InboxMutationResult.java`
|
|
- `InboxItemEntity.java`
|
|
- `JpaNotificationInbox.java`
|
|
- `InboxUnreadCounter.java`
|
|
|
|
```java
|
|
public interface NotificationInbox {
|
|
InboxPage list(InboxQuery query);
|
|
InboxMutationResult markSeen(InboxItemId id, InboxPrincipal principal);
|
|
InboxMutationResult markRead(InboxItemId id, InboxPrincipal principal);
|
|
InboxMutationResult archive(InboxItemId id, InboxPrincipal principal);
|
|
long unreadCount(InboxPrincipal principal);
|
|
}
|
|
|
|
// Query ordering: created_at DESC, id DESC.
|
|
// Mark-read SQL updates only rows owned by tenant/user and only when read_at IS NULL.
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `./gradlew :modules:notification:notification-inbox-jpa:test`
|
|
|
|
Expected: PASS: cursor pagination, concurrent read, unread count, tenant guard가 통과한다.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add 'modules/notification/notification-inbox-api/src/main/java/io/backend/skeleton/notification/inbox/NotificationInbox.java' 'modules/notification/notification-inbox-api/src/main/java/io/backend/skeleton/notification/inbox/InboxItem.java' 'modules/notification/notification-inbox-api/src/main/java/io/backend/skeleton/notification/inbox/InboxCursor.java' 'modules/notification/notification-inbox-api/src/main/java/io/backend/skeleton/notification/inbox/InboxItemId.java' 'modules/notification/notification-inbox-api/src/main/java/io/backend/skeleton/notification/inbox/InboxPrincipal.java' 'modules/notification/notification-inbox-api/src/main/java/io/backend/skeleton/notification/inbox/InboxPage.java' 'modules/notification/notification-inbox-api/src/main/java/io/backend/skeleton/notification/inbox/InboxQuery.java' 'modules/notification/notification-inbox-api/src/main/java/io/backend/skeleton/notification/inbox/InboxMutationResult.java' 'modules/notification/notification-inbox-jpa/src/main/java/io/backend/skeleton/notification/inbox/jpa/InboxItemEntity.java' 'modules/notification/notification-inbox-jpa/src/main/java/io/backend/skeleton/notification/inbox/jpa/JpaNotificationInbox.java' 'modules/notification/notification-inbox-jpa/src/main/java/io/backend/skeleton/notification/inbox/jpa/InboxUnreadCounter.java' 'modules/notification/notification-inbox-jpa/src/test/java/io/backend/skeleton/notification/inbox/jpa/JpaNotificationInboxTest.java'
|
|
git commit -m "feat(notification): add durable in-app inbox"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 40: Inbox Commit Event와 WebSocket Signal Integration 구현
|
|
|
|
**Files:**
|
|
- Create: `modules/notification/notification-inbox-api/src/main/java/io/backend/skeleton/notification/inbox/InboxItemCreated.java`
|
|
- Create: `modules/notification/notification-inbox-jpa/src/main/java/io/backend/skeleton/notification/inbox/jpa/InboxCommitEventPublisher.java`
|
|
- Create: `modules/notification/notification-inbox-jpa/src/main/java/io/backend/skeleton/notification/inbox/jpa/InboxOutboxRecordFactory.java`
|
|
- Test: `modules/notification/notification-inbox-jpa/src/test/java/io/backend/skeleton/notification/inbox/jpa/InboxSignalDurabilityTest.java`
|
|
|
|
**Interfaces:**
|
|
- Consumes: Task 39 Inbox, existing messaging/outbox and websocket capability.
|
|
- Produces: DB commit source-of-truth, post-commit/outbox signal, WebSocket failure isolation.
|
|
|
|
**Implementation requirements:**
|
|
- messaging Outbox가 존재하면 재사용하고 없으면 local post-commit adapter를 선택한다.
|
|
- signal payload에 message body와 contact point를 넣지 않는다.
|
|
- WebSocket signal은 unread count의 source of truth가 아니다.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```java
|
|
package io.backend.skeleton.notification.inbox.jpa;
|
|
|
|
import org.junit.jupiter.api.Test;
|
|
import static org.assertj.core.api.Assertions.*;
|
|
|
|
class InboxSignalDurabilityTest extends PostgreSqlNotificationTest {
|
|
@Test
|
|
void websocketFailureDoesNotRollbackInboxItem() {
|
|
var publisher = fixture().failingSignalPublisher();
|
|
var service = inboxService(publisher);
|
|
var itemId = service.create(fixture().command());
|
|
assertThat(inboxRepository().findById(itemId.value())).isPresent();
|
|
assertThat(signalRetryQueue()).isNotEmpty();
|
|
}
|
|
|
|
@Test
|
|
void signalIsNotPublishedBeforeCommit() {
|
|
fixture().transactionThatRollsBack(() -> inboxService().create(fixture().command()));
|
|
assertThat(publishedSignals()).isEmpty();
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `./gradlew :modules:notification:notification-inbox-jpa:test --tests "*.InboxSignalDurabilityTest"`
|
|
|
|
Expected: FAIL: commit event publisher가 없다.
|
|
|
|
- [ ] **Step 3: Write the minimal implementation**
|
|
|
|
Files to implement:
|
|
- `InboxItemCreated.java`
|
|
- `InboxCommitEventPublisher.java`
|
|
- `InboxOutboxRecordFactory.java`
|
|
|
|
```java
|
|
@Transactional
|
|
public InboxItemId create(CreateInboxItem command) {
|
|
var entity = repository.save(factory.create(command));
|
|
outboxRepository.save(outboxFactory.inboxCreated(entity));
|
|
return new InboxItemId(entity.id());
|
|
}
|
|
|
|
// Relay publishes InboxItemCreated after DB commit. WebSocket adapter consumes
|
|
// the event; delivery failure retries the signal without altering inbox data.
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `./gradlew :modules:notification:notification-inbox-jpa:test`
|
|
|
|
Expected: PASS: Inbox write와 signal failure가 분리되고 rollback transaction은 signal을 만들지 않는다.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add 'modules/notification/notification-inbox-api/src/main/java/io/backend/skeleton/notification/inbox/InboxItemCreated.java' 'modules/notification/notification-inbox-jpa/src/main/java/io/backend/skeleton/notification/inbox/jpa/InboxCommitEventPublisher.java' 'modules/notification/notification-inbox-jpa/src/main/java/io/backend/skeleton/notification/inbox/jpa/InboxOutboxRecordFactory.java' 'modules/notification/notification-inbox-jpa/src/test/java/io/backend/skeleton/notification/inbox/jpa/InboxSignalDurabilityTest.java'
|
|
git commit -m "feat(notification): integrate inbox commit signals"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 41: Opt-in Deduplication과 Provider Collapse Mapping 구현
|
|
|
|
**Files:**
|
|
- Create: `modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/DeduplicationService.java`
|
|
- Create: `modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/CollapseCapability.java`
|
|
- Test: `modules/notification/notification-policy/src/test/java/io/backend/skeleton/notification/policy/DeduplicationAndCollapseTest.java`
|
|
|
|
**Interfaces:**
|
|
- Consumes: Task 4 DeduplicationSpec·CollapseSpec, Task 15 submit, Tasks 34·36·38 provider adapters.
|
|
- Produces: Recipient/window-scoped dedup and provider-specific collapse hints without delivery guarantee.
|
|
|
|
**Implementation requirements:**
|
|
- Deduplication은 명시적 spec이 없으면 실행하지 않는다.
|
|
- collapse key는 provider length/character limit을 사전 검증한다.
|
|
- collapse가 이미 전달된 알림을 취소한다고 표현하지 않는다.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```java
|
|
package io.backend.skeleton.notification.policy;
|
|
|
|
import org.junit.jupiter.api.Test;
|
|
import java.time.Duration;
|
|
import static org.assertj.core.api.Assertions.*;
|
|
|
|
class DeduplicationAndCollapseTest extends PostgreSqlNotificationTest {
|
|
@Test
|
|
void dedupSuppressesSecondLogicalNotificationWithinWindow() {
|
|
var spec = new DeduplicationSpec("order-1-delay", Duration.ofMinutes(30),
|
|
DeduplicationAction.RETURN_EXISTING);
|
|
var first = service().evaluate(fixture().recipient("user-1"), spec);
|
|
var second = service().evaluate(fixture().recipient("user-1"), spec);
|
|
assertThat(second.existingNotificationId()).contains(first.notificationId());
|
|
}
|
|
|
|
@Test
|
|
void collapseDoesNotReportExactlyOnceOrDelivered() {
|
|
var mapped = collapseCapability().map(new CollapseSpec("feed:user-1", CollapseScope.RECIPIENT));
|
|
assertThat(mapped.guaranteesSingleUserNotification()).isFalse();
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `./gradlew :modules:notification:notification-policy:test --tests "*.DeduplicationAndCollapseTest"`
|
|
|
|
Expected: FAIL: dedup/collapse types와 service가 없다.
|
|
|
|
- [ ] **Step 3: Write the minimal implementation**
|
|
|
|
Files to implement:
|
|
- `DeduplicationService.java`
|
|
- `CollapseCapability.java`
|
|
|
|
```java
|
|
@Transactional
|
|
public DeduplicationResult evaluate(RecipientIdentity recipient, DeduplicationSpec spec) {
|
|
var bucket = bucketClock.bucket(clock.instant(), spec.window());
|
|
return repository.insertOrFind(
|
|
recipient.tenantId(), recipient.recipientRef(), spec.dedupKey(), bucket);
|
|
}
|
|
|
|
public interface CollapseCapability {
|
|
ProviderCollapseMapping map(CollapseSpec spec);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `./gradlew :modules:notification:notification-policy:test`
|
|
|
|
Expected: PASS: dedup은 logical notification에 적용되고 collapse는 provider transport hint로만 동작한다.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add 'modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/DeduplicationService.java' 'modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/CollapseCapability.java' 'modules/notification/notification-policy/src/test/java/io/backend/skeleton/notification/policy/DeduplicationAndCollapseTest.java'
|
|
git commit -m "feat(notification): add deduplication and collapse capabilities"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 42: Webhook Notification Extension을 HTTP Client Platform 위에 구현
|
|
|
|
**Files:**
|
|
- Create: `modules/notification/notification-webhook-extension/src/main/java/io/backend/skeleton/notification/webhook/WebhookNotification.java`
|
|
- Create: `modules/notification/notification-webhook-extension/src/main/java/io/backend/skeleton/notification/webhook/WebhookNotificationProviderAdapter.java`
|
|
- Create: `modules/notification/notification-webhook-extension/src/main/java/io/backend/skeleton/notification/webhook/WebhookSignatureStrategy.java`
|
|
- Create: `modules/notification/notification-webhook-extension/src/main/java/io/backend/skeleton/notification/webhook/WebhookSubscription.java`
|
|
- Test: `modules/notification/notification-webhook-extension/src/test/java/io/backend/skeleton/notification/webhook/WebhookNotificationProviderAdapterTest.java`
|
|
|
|
**Interfaces:**
|
|
- Consumes: Task 10 provider SPI and existing HTTP Client H1/H3 gateways.
|
|
- Produces: Webhook extension with trusted/dynamic target separation, request signing and HTTP execution evidence mapping.
|
|
|
|
**Implementation requirements:**
|
|
- HTTP retry, TLS, SSRF, redirect, timeout을 새로 구현하지 않는다.
|
|
- webhook target과 signing secret을 사용자 입력으로 한 요청에서 동시에 받지 않는다.
|
|
- response body는 bounded diagnostic metadata만 보존한다.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```java
|
|
package io.backend.skeleton.notification.webhook;
|
|
|
|
import org.junit.jupiter.api.Test;
|
|
import static org.assertj.core.api.Assertions.*;
|
|
|
|
class WebhookNotificationProviderAdapterTest extends HttpClientFixtureBase {
|
|
@Test
|
|
void dynamicTargetNeverInheritsTrustedCredential() {
|
|
adapter().submit(fixture().dynamicSubmission()).toCompletableFuture().join();
|
|
assertThat(recordedRequest().headers()).doesNotContainKeys("Authorization", "Cookie");
|
|
}
|
|
|
|
@Test
|
|
void sentNoResponseMapsToAmbiguous() {
|
|
server().acceptBodyThenReset();
|
|
var result = adapter().submit(fixture().trustedSubmission()).toCompletableFuture().join();
|
|
assertThat(result.confirmation()).isEqualTo(AttemptConfirmation.AMBIGUOUS);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `./gradlew :modules:notification:notification-webhook-extension:test --tests "*.WebhookNotificationProviderAdapterTest"`
|
|
|
|
Expected: FAIL: webhook extension이 없다.
|
|
|
|
- [ ] **Step 3: Write the minimal implementation**
|
|
|
|
Files to implement:
|
|
- `WebhookNotification.java`
|
|
- `WebhookNotificationProviderAdapter.java`
|
|
- `WebhookSignatureStrategy.java`
|
|
- `WebhookSubscription.java`
|
|
|
|
```java
|
|
public CompletionStage<ProviderSubmissionResult> submit(ProviderSubmission submission) {
|
|
var webhook = mapper.map(submission);
|
|
var call = webhook.subscription().trusted()
|
|
? trustedGateway.exchange(webhook.profileName(), webhook.operation(), RESPONSE)
|
|
: dynamicGateway.exchange(webhook.dynamicPolicy(), webhook.target(), webhook.operation(), RESPONSE);
|
|
return call.handle((result, error) -> evidenceMapper.map(result, error));
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `./gradlew :modules:notification:notification-webhook-extension:test`
|
|
|
|
Expected: PASS: credential isolation과 HTTP ambiguity mapping이 검증된다.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add 'modules/notification/notification-webhook-extension/src/main/java/io/backend/skeleton/notification/webhook/WebhookNotification.java' 'modules/notification/notification-webhook-extension/src/main/java/io/backend/skeleton/notification/webhook/WebhookNotificationProviderAdapter.java' 'modules/notification/notification-webhook-extension/src/main/java/io/backend/skeleton/notification/webhook/WebhookSignatureStrategy.java' 'modules/notification/notification-webhook-extension/src/main/java/io/backend/skeleton/notification/webhook/WebhookSubscription.java' 'modules/notification/notification-webhook-extension/src/test/java/io/backend/skeleton/notification/webhook/WebhookNotificationProviderAdapterTest.java'
|
|
git commit -m "feat(notification): add webhook delivery extension"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 43: Notification Metrics·Tracing·Audit 구현
|
|
|
|
**Files:**
|
|
- Create: `modules/notification/notification-observability/src/main/java/io/backend/skeleton/notification/observation/NotificationObservationConvention.java`
|
|
- Create: `modules/notification/notification-observability/src/main/java/io/backend/skeleton/notification/observation/NotificationMetrics.java`
|
|
- Create: `modules/notification/notification-observability/src/main/java/io/backend/skeleton/notification/observation/NotificationAuditLogger.java`
|
|
- Create: `modules/notification/notification-observability/src/main/java/io/backend/skeleton/notification/observation/CardinalityGuard.java`
|
|
- Test: `modules/notification/notification-observability/src/test/java/io/backend/skeleton/notification/observation/NotificationObservabilityTest.java`
|
|
|
|
**Interfaces:**
|
|
- Consumes: Tasks 15 submit, 21 dispatcher, 22 callback, 25 reconciliation.
|
|
- Produces: Logical/recipient/attempt observations, bounded tags, callback trace links and audited admin changes.
|
|
|
|
**Implementation requirements:**
|
|
- templateId는 registry에 등록된 bounded ID일 때만 tag로 허용한다.
|
|
- raw provider status는 normalized bounded status로 변환한다.
|
|
- audit record에도 Contact Point 원문을 쓰지 않는다.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```java
|
|
package io.backend.skeleton.notification.observation;
|
|
|
|
import org.junit.jupiter.api.Test;
|
|
import static org.assertj.core.api.Assertions.*;
|
|
|
|
class NotificationObservabilityTest {
|
|
@Test
|
|
void metricTagsNeverContainHighCardinalityIdentifiers() {
|
|
var observation = fixture().dispatchObservation();
|
|
assertThat(observation.lowCardinalityTags().keySet())
|
|
.doesNotContain("notificationId", "recipientId", "attemptId",
|
|
"providerRequestId", "email", "phone", "token");
|
|
}
|
|
|
|
@Test
|
|
void callbackUsesTraceLinkInsteadOfLongRunningChildSpan() {
|
|
var trace = fixture().callbackTrace();
|
|
assertThat(trace.links()).contains(fixture().originalDispatchContext());
|
|
assertThat(trace.parent()).isEmpty();
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `./gradlew :modules:notification:notification-observability:test --tests "*.NotificationObservabilityTest"`
|
|
|
|
Expected: FAIL: observation convention과 cardinality guard가 없다.
|
|
|
|
- [ ] **Step 3: Write the minimal implementation**
|
|
|
|
Files to implement:
|
|
- `NotificationObservationConvention.java`
|
|
- `NotificationMetrics.java`
|
|
- `NotificationAuditLogger.java`
|
|
- `CardinalityGuard.java`
|
|
|
|
```java
|
|
public final class CardinalityGuard {
|
|
private static final java.util.Set<String> ALLOWED_TAGS = java.util.Set.of(
|
|
"channel", "provider", "templateId", "notificationCategory",
|
|
"status", "failureCategory", "attemptBucket", "sizeBucket");
|
|
|
|
public void validate(java.util.Map<String, String> tags) {
|
|
if (!ALLOWED_TAGS.containsAll(tags.keySet())) {
|
|
throw new IllegalMetricTagException(tags.keySet());
|
|
}
|
|
}
|
|
}
|
|
|
|
// NotificationMetrics exposes requested, suppressed, render, dispatch,
|
|
// accepted, delivery, retry, fallback, ambiguous, callback, reconcile and queue metrics.
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `./gradlew :modules:notification:notification-observability:test`
|
|
|
|
Expected: PASS: high-cardinality/PII tag가 차단되고 callback trace link가 검증된다.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add 'modules/notification/notification-observability/src/main/java/io/backend/skeleton/notification/observation/NotificationObservationConvention.java' 'modules/notification/notification-observability/src/main/java/io/backend/skeleton/notification/observation/NotificationMetrics.java' 'modules/notification/notification-observability/src/main/java/io/backend/skeleton/notification/observation/NotificationAuditLogger.java' 'modules/notification/notification-observability/src/main/java/io/backend/skeleton/notification/observation/CardinalityGuard.java' 'modules/notification/notification-observability/src/test/java/io/backend/skeleton/notification/observation/NotificationObservabilityTest.java'
|
|
git commit -m "feat(notification): add metrics tracing and audit"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 44: PII·Secret Redaction과 구조 경계 ArchUnit 구현
|
|
|
|
**Files:**
|
|
- Create: `modules/notification/notification-security/src/main/java/io/backend/skeleton/notification/security/NotificationRedactor.java`
|
|
- Create: `modules/notification/notification-security/src/main/java/io/backend/skeleton/notification/security/SensitiveValueClassifier.java`
|
|
- Create: `modules/notification/notification-security/src/main/java/io/backend/skeleton/notification/security/SafeDiagnosticContext.java`
|
|
- Test: `modules/notification/notification-security/src/test/java/io/backend/skeleton/notification/security/NotificationRedactionTest.java`
|
|
- Test: `modules/notification/notification-security/src/test/java/io/backend/skeleton/notification/security/NotificationArchitectureTest.java`
|
|
|
|
**Interfaces:**
|
|
- Consumes: Task 7 protector, Task 43 observability.
|
|
- Produces: Central redaction, safe diagnostic context, provider SDK dependency boundary and raw secret leak tests.
|
|
|
|
**Implementation requirements:**
|
|
- exception/logging interceptor 모두 NotificationRedactor를 사용한다.
|
|
- 일반 SHA-256 주소 fingerprint를 저장하지 않고 keyed HMAC을 사용한다.
|
|
- test output과 assertion failure에도 secret fixture 원문을 출력하지 않도록 custom representation을 사용한다.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```java
|
|
package io.backend.skeleton.notification.security;
|
|
|
|
import org.junit.jupiter.api.Test;
|
|
import static org.assertj.core.api.Assertions.*;
|
|
|
|
class NotificationRedactionTest {
|
|
@Test
|
|
void redactsAllContactAndCredentialKinds() {
|
|
var input = "user@example.com +821012345678 fcm-token webpush-endpoint bearer-secret";
|
|
var redacted = redactor().redact(input, fixture().classifiedValues());
|
|
assertThat(redacted)
|
|
.doesNotContain("user@example.com", "+821012345678", "fcm-token",
|
|
"webpush-endpoint", "bearer-secret");
|
|
}
|
|
|
|
@Test
|
|
void safeContextAcceptsOnlyBoundedFields() {
|
|
assertThatThrownBy(() -> SafeDiagnosticContext.builder()
|
|
.put("providerRequestId", "SM123").build())
|
|
.isInstanceOf(UnsafeDiagnosticFieldException.class);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `./gradlew :modules:notification:notification-security:test --tests "*.NotificationRedactionTest"`
|
|
|
|
Expected: FAIL: redactor와 architecture rule이 없다.
|
|
|
|
- [ ] **Step 3: Write the minimal implementation**
|
|
|
|
Files to implement:
|
|
- `NotificationRedactor.java`
|
|
- `SensitiveValueClassifier.java`
|
|
- `SafeDiagnosticContext.java`
|
|
- `NotificationArchitectureTest.java`
|
|
|
|
```java
|
|
public final class SafeDiagnosticContext {
|
|
private static final java.util.Set<String> ALLOWED = java.util.Set.of(
|
|
"channel", "provider", "operation", "result", "failureCategory",
|
|
"templateId", "attemptBucket");
|
|
// Builder rejects keys outside ALLOWED and normalizes values to bounded enums/IDs.
|
|
}
|
|
|
|
// ArchUnit rules:
|
|
// core/content/contact packages must not depend on Firebase, Twilio, AWS SDK,
|
|
// Jakarta Mail, JPA, MVC or WebFlux packages.
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `./gradlew :modules:notification:notification-security:test`
|
|
|
|
Expected: PASS: contact/credential redaction과 architecture dependency guard가 통과한다.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add 'modules/notification/notification-security/src/main/java/io/backend/skeleton/notification/security/NotificationRedactor.java' 'modules/notification/notification-security/src/main/java/io/backend/skeleton/notification/security/SensitiveValueClassifier.java' 'modules/notification/notification-security/src/main/java/io/backend/skeleton/notification/security/SafeDiagnosticContext.java' 'modules/notification/notification-security/src/test/java/io/backend/skeleton/notification/security/NotificationRedactionTest.java' 'modules/notification/notification-security/src/test/java/io/backend/skeleton/notification/security/NotificationArchitectureTest.java'
|
|
git commit -m "test(notification): enforce PII and architecture boundaries"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 45: N4 Admin Redrive·Reconcile·Suppression·Provider Control 구현
|
|
|
|
**Files:**
|
|
- Create: `modules/notification/notification-admin-api/src/main/java/io/backend/skeleton/notification/admin/NotificationAdminService.java`
|
|
- Create: `modules/notification/notification-admin-api/src/main/java/io/backend/skeleton/notification/admin/AdminActor.java`
|
|
- Create: `modules/notification/notification-admin-api/src/main/java/io/backend/skeleton/notification/admin/RedriveCommand.java`
|
|
- Create: `modules/notification/notification-admin-runtime/src/main/java/io/backend/skeleton/notification/admin/NotificationAdminServiceImpl.java`
|
|
- Create: `modules/notification/notification-admin-runtime/src/main/java/io/backend/skeleton/notification/admin/AdminAuthorizationGuard.java`
|
|
- Test: `modules/notification/notification-admin-runtime/src/test/java/io/backend/skeleton/notification/admin/NotificationAdminServiceTest.java`
|
|
|
|
**Interfaces:**
|
|
- Consumes: Tasks 17 suppression, 25 reconciliation, 20 runtime, 43 audit.
|
|
- Produces: Audited N4 operations, dry-run, bounded batch, duplicate-risk approval, original identity preservation.
|
|
|
|
**Implementation requirements:**
|
|
- Admin operation ID는 idempotent해야 한다.
|
|
- dry-run은 DB/Provider 상태를 변경하지 않는다.
|
|
- bulk redrive는 rate limit과 max batch size를 적용한다.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```java
|
|
package io.backend.skeleton.notification.admin;
|
|
|
|
import org.junit.jupiter.api.Test;
|
|
import static org.assertj.core.api.Assertions.*;
|
|
|
|
class NotificationAdminServiceTest extends PostgreSqlNotificationTest {
|
|
@Test
|
|
void applicationAuthorityCannotRedrive() {
|
|
assertThatThrownBy(() -> service().redrive(
|
|
fixture().redriveCommand(), fixture().applicationActor()))
|
|
.isInstanceOf(AdminAccessDeniedException.class);
|
|
}
|
|
|
|
@Test
|
|
void redrivePreservesLogicalIdsAndCreatesNewAttempt() {
|
|
var original = insertFailedAttempt();
|
|
var result = service().redrive(fixture().approvedRedrive(original.id()), fixture().adminActor());
|
|
assertThat(result.notificationId()).isEqualTo(original.notificationId());
|
|
assertThat(result.recipientDeliveryId()).isEqualTo(original.recipientDeliveryId());
|
|
assertThat(result.newAttemptId()).isNotEqualTo(original.id());
|
|
assertThat(auditCount(result.operationId())).isEqualTo(1);
|
|
}
|
|
|
|
@Test
|
|
void ambiguousRedriveRequiresDuplicateRiskApproval() {
|
|
var ambiguous = insertAmbiguousAttempt();
|
|
assertThatThrownBy(() -> service().redrive(
|
|
fixture().redriveWithoutRiskApproval(ambiguous.id()), fixture().adminActor()))
|
|
.isInstanceOf(DuplicateRiskApprovalRequiredException.class);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `./gradlew :modules:notification:notification-admin-runtime:test --tests "*.NotificationAdminServiceTest"`
|
|
|
|
Expected: FAIL: admin API/runtime가 없다.
|
|
|
|
- [ ] **Step 3: Write the minimal implementation**
|
|
|
|
Files to implement:
|
|
- `NotificationAdminService.java`
|
|
- `AdminActor.java`
|
|
- `RedriveCommand.java`
|
|
- `NotificationAdminServiceImpl.java`
|
|
- `AdminAuthorizationGuard.java`
|
|
|
|
```java
|
|
public interface NotificationAdminService {
|
|
AdminOperationResult redrive(RedriveCommand command, AdminActor actor);
|
|
AdminOperationResult reconcile(ReconcileCommand command, AdminActor actor);
|
|
AdminOperationResult suppress(SuppressCommand command, AdminActor actor);
|
|
AdminOperationResult setProviderState(SetProviderStateCommand command, AdminActor actor);
|
|
}
|
|
|
|
@Transactional
|
|
public AdminOperationResult redrive(RedriveCommand command, AdminActor actor) {
|
|
authorization.require(actor, NotificationAdminAuthority.REDRIVE);
|
|
var original = attempts.load(command.attemptId());
|
|
duplicateRiskGuard.verify(original, command.approveDuplicateRisk());
|
|
var operation = operations.begin(command.operationId(), actor, command.reason());
|
|
var newAttempt = redriveFactory.create(original, operation.id());
|
|
audit.recordRedrive(operation, original, newAttempt);
|
|
return result(newAttempt, operation);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `./gradlew :modules:notification:notification-admin-runtime:test`
|
|
|
|
Expected: PASS: 권한, identity 보존, duplicate-risk approval, audit가 검증된다.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add 'modules/notification/notification-admin-api/src/main/java/io/backend/skeleton/notification/admin/NotificationAdminService.java' 'modules/notification/notification-admin-api/src/main/java/io/backend/skeleton/notification/admin/AdminActor.java' 'modules/notification/notification-admin-api/src/main/java/io/backend/skeleton/notification/admin/RedriveCommand.java' 'modules/notification/notification-admin-runtime/src/main/java/io/backend/skeleton/notification/admin/NotificationAdminServiceImpl.java' 'modules/notification/notification-admin-runtime/src/main/java/io/backend/skeleton/notification/admin/AdminAuthorizationGuard.java' 'modules/notification/notification-admin-runtime/src/test/java/io/backend/skeleton/notification/admin/NotificationAdminServiceTest.java'
|
|
git commit -m "feat(notification): add audited notification admin plane"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 46: Credential·Certificate Rotation과 Runtime Generation Drain 구현
|
|
|
|
**Files:**
|
|
- Create: `modules/notification/notification-security/src/main/java/io/backend/skeleton/notification/security/ProviderCredentialManager.java`
|
|
- Create: `modules/notification/notification-security/src/main/java/io/backend/skeleton/notification/security/CredentialGeneration.java`
|
|
- Create: `modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/ProviderRuntimeRotator.java`
|
|
- Create: `modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/RuntimeDrainCoordinator.java`
|
|
- Test: `modules/notification/notification-dispatch-runtime/src/test/java/io/backend/skeleton/notification/dispatch/ProviderRuntimeRotationTest.java`
|
|
|
|
**Interfaces:**
|
|
- Consumes: Task 7 secret provider, Task 20 runtime registry, Task 45 admin control.
|
|
- Produces: Immutable runtime generation replacement, in-flight drain, attempt generation audit, rollback.
|
|
|
|
**Implementation requirements:**
|
|
- Contact Point encryption key rotation은 별도 background re-encryption job이다.
|
|
- VAPID key rotation은 restricted subscription migration을 요구하므로 generic rotation으로 처리하지 않는다.
|
|
- credential material을 audit에 기록하지 않고 generation/key ID만 기록한다.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```java
|
|
package io.backend.skeleton.notification.dispatch;
|
|
|
|
import org.junit.jupiter.api.Test;
|
|
import java.time.Duration;
|
|
import static org.assertj.core.api.Assertions.*;
|
|
|
|
class ProviderRuntimeRotationTest {
|
|
@Test
|
|
void newAttemptUsesNewGenerationWhileOldAttemptDrains() {
|
|
var oldPermit = registry().current("apns-main").acquireAttempt();
|
|
rotator().rotate("apns-main", fixture().credentialGeneration(2));
|
|
var newPermit = registry().current("apns-main").acquireAttempt();
|
|
assertThat(oldPermit.generation()).isEqualTo(1);
|
|
assertThat(newPermit.generation()).isEqualTo(2);
|
|
oldPermit.close();
|
|
assertThat(registry().drainingGenerations("apns-main")).isEmpty();
|
|
}
|
|
|
|
@Test
|
|
void failedNewCredentialKeepsOldRuntimeActive() {
|
|
assertThatThrownBy(() -> rotator().rotate(
|
|
"ses-primary", fixture().invalidCredentialGeneration(2)))
|
|
.isInstanceOf(CredentialValidationException.class);
|
|
assertThat(registry().current("ses-primary").generation()).isEqualTo(1);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `./gradlew :modules:notification:notification-dispatch-runtime:test --tests "*.ProviderRuntimeRotationTest"`
|
|
|
|
Expected: FAIL: credential manager/rotator가 없다.
|
|
|
|
- [ ] **Step 3: Write the minimal implementation**
|
|
|
|
Files to implement:
|
|
- `ProviderCredentialManager.java`
|
|
- `CredentialGeneration.java`
|
|
- `ProviderRuntimeRotator.java`
|
|
- `RuntimeDrainCoordinator.java`
|
|
|
|
```java
|
|
public void rotate(ProviderProfileId profileId, CredentialGeneration generation) {
|
|
var candidate = runtimeFactory.create(profileId, generation);
|
|
credentialProbe.validate(candidate);
|
|
var previous = registry.swap(profileId, candidate);
|
|
previous.markDraining();
|
|
drainCoordinator.drain(previous, properties.drainTimeout());
|
|
}
|
|
|
|
// If validation fails before swap, previous runtime remains current.
|
|
// If drain timeout expires, cancel/close according to provider-specific safe shutdown contract.
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `./gradlew :modules:notification:notification-dispatch-runtime:test`
|
|
|
|
Expected: PASS: new generation cutover, old drain, failed candidate rollback이 검증된다.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add 'modules/notification/notification-security/src/main/java/io/backend/skeleton/notification/security/ProviderCredentialManager.java' 'modules/notification/notification-security/src/main/java/io/backend/skeleton/notification/security/CredentialGeneration.java' 'modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/ProviderRuntimeRotator.java' 'modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/RuntimeDrainCoordinator.java' 'modules/notification/notification-dispatch-runtime/src/test/java/io/backend/skeleton/notification/dispatch/ProviderRuntimeRotationTest.java'
|
|
git commit -m "feat(notification): add credential rotation and runtime draining"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 47: Spring Boot Starter·Properties Validation·Actuator 구현
|
|
|
|
**Files:**
|
|
- Create: `modules/notification/notification-spring-boot-starter/src/main/java/io/backend/skeleton/notification/autoconfigure/NotificationProperties.java`
|
|
- Create: `modules/notification/notification-spring-boot-starter/src/main/java/io/backend/skeleton/notification/autoconfigure/NotificationCoreAutoConfiguration.java`
|
|
- Create: `modules/notification/notification-spring-boot-starter/src/main/java/io/backend/skeleton/notification/autoconfigure/NotificationProviderAutoConfiguration.java`
|
|
- Create: `modules/notification/notification-spring-boot-starter/src/main/java/io/backend/skeleton/notification/autoconfigure/NotificationCallbackAutoConfiguration.java`
|
|
- Create: `modules/notification/notification-spring-boot-starter/src/main/java/io/backend/skeleton/notification/autoconfigure/NotificationHealthEndpoint.java`
|
|
- Create: `modules/notification/notification-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports`
|
|
- Test: `modules/notification/notification-spring-boot-starter/src/test/java/io/backend/skeleton/notification/autoconfigure/NotificationAutoConfigurationTest.java`
|
|
|
|
**Interfaces:**
|
|
- Consumes: All Core/Adapter runtime contracts from Tasks 1-46.
|
|
- Produces: Conditional beans, startup guardrails, MVC/WebFlux selection, non-sensitive health endpoints.
|
|
|
|
**Implementation requirements:**
|
|
- Health endpoint는 provider state/generation/queue age만 노출하고 secret/address를 제외한다.
|
|
- MVC와 WebFlux callback auto-config가 동시에 endpoint를 등록하지 않는다.
|
|
- Production trust-all, unbounded queue, ambiguous fallback enable을 거부한다.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```java
|
|
package io.backend.skeleton.notification.autoconfigure;
|
|
|
|
import org.junit.jupiter.api.Test;
|
|
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
|
import static org.assertj.core.api.Assertions.*;
|
|
|
|
class NotificationAutoConfigurationTest {
|
|
private final ApplicationContextRunner runner = new ApplicationContextRunner()
|
|
.withConfiguration(org.springframework.boot.autoconfigure.AutoConfigurations.of(
|
|
NotificationCoreAutoConfiguration.class,
|
|
NotificationProviderAutoConfiguration.class));
|
|
|
|
@Test
|
|
void productionWebPushWithoutVapidKeyFailsStartup() {
|
|
runner.withPropertyValues(
|
|
"notification.providers.webpush.type=WEB_PUSH",
|
|
"notification.providers.webpush.enabled=true",
|
|
"notification.providers.webpush.environment=PRODUCTION")
|
|
.run(context -> assertThat(context.getStartupFailure())
|
|
.hasMessageContaining("VAPID"));
|
|
}
|
|
|
|
@Test
|
|
void adminBeansAreAbsentByDefault() {
|
|
runner.run(context -> assertThat(context)
|
|
.doesNotHaveBean(NotificationAdminService.class));
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `./gradlew :modules:notification:notification-spring-boot-starter:test --tests "*.NotificationAutoConfigurationTest"`
|
|
|
|
Expected: FAIL: starter properties와 auto-configuration이 없다.
|
|
|
|
- [ ] **Step 3: Write the minimal implementation**
|
|
|
|
Files to implement:
|
|
- `NotificationProperties.java`
|
|
- `NotificationCoreAutoConfiguration.java`
|
|
- `NotificationProviderAutoConfiguration.java`
|
|
- `NotificationCallbackAutoConfiguration.java`
|
|
- `NotificationHealthEndpoint.java`
|
|
- `AutoConfiguration.imports`
|
|
|
|
```java
|
|
@ConfigurationProperties("notification")
|
|
public record NotificationProperties(
|
|
DispatchProperties dispatch,
|
|
java.util.Map<String, ProviderProperties> providers,
|
|
CallbackProperties callbacks,
|
|
SecurityProperties security
|
|
) {
|
|
public NotificationProperties {
|
|
validateBounded(dispatch.claimBatchSize(), 1, 1000, "claimBatchSize");
|
|
providers.forEach((id, profile) -> profile.validate(id));
|
|
}
|
|
}
|
|
|
|
@AutoConfiguration
|
|
@EnableConfigurationProperties(NotificationProperties.class)
|
|
public class NotificationCoreAutoConfiguration {}
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `./gradlew :modules:notification:notification-spring-boot-starter:test`
|
|
|
|
Expected: PASS: missing secrets/invalid bounds가 startup 실패하고 Admin은 opt-in이다.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add 'modules/notification/notification-spring-boot-starter/src/main/java/io/backend/skeleton/notification/autoconfigure/NotificationProperties.java' 'modules/notification/notification-spring-boot-starter/src/main/java/io/backend/skeleton/notification/autoconfigure/NotificationCoreAutoConfiguration.java' 'modules/notification/notification-spring-boot-starter/src/main/java/io/backend/skeleton/notification/autoconfigure/NotificationProviderAutoConfiguration.java' 'modules/notification/notification-spring-boot-starter/src/main/java/io/backend/skeleton/notification/autoconfigure/NotificationCallbackAutoConfiguration.java' 'modules/notification/notification-spring-boot-starter/src/main/java/io/backend/skeleton/notification/autoconfigure/NotificationHealthEndpoint.java' 'modules/notification/notification-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports' 'modules/notification/notification-spring-boot-starter/src/test/java/io/backend/skeleton/notification/autoconfigure/NotificationAutoConfigurationTest.java'
|
|
git commit -m "feat(notification): add Spring Boot starter and health endpoints"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 48: Reactor Facade와 Cancellation·Context 계약 구현
|
|
|
|
**Files:**
|
|
- Create: `modules/notification/notification-reactor/src/main/java/io/backend/skeleton/notification/reactor/ReactiveNotificationOrchestrator.java`
|
|
- Create: `modules/notification/notification-reactor/src/main/java/io/backend/skeleton/notification/reactor/ReactorNotificationOrchestrator.java`
|
|
- Create: `modules/notification/notification-reactor/src/main/java/io/backend/skeleton/notification/reactor/ReactorContextBridge.java`
|
|
- Test: `modules/notification/notification-reactor/src/test/java/io/backend/skeleton/notification/reactor/ReactorNotificationOrchestratorTest.java`
|
|
|
|
**Interfaces:**
|
|
- Consumes: Task 5 synchronous durable API, CompletionStage core.
|
|
- Produces: Mono facade, context propagation, cancellation semantics without cancelling already committed submit.
|
|
|
|
**Implementation requirements:**
|
|
- `.block()`을 facade 내부에서 사용하지 않는다.
|
|
- JPA blocking operation은 boundedElastic/virtual-thread adapter 경계에서 실행한다.
|
|
- submit cancellation은 이미 commit된 논리 요청을 자동 취소하지 않는다.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```java
|
|
package io.backend.skeleton.notification.reactor;
|
|
|
|
import org.junit.jupiter.api.Test;
|
|
import reactor.test.StepVerifier;
|
|
import static org.assertj.core.api.Assertions.*;
|
|
|
|
class ReactorNotificationOrchestratorTest {
|
|
@Test
|
|
void propagatesCorrelationContext() {
|
|
StepVerifier.create(reactive().submit(fixture().plan())
|
|
.contextWrite(context -> context.put("correlationId", "corr-1")))
|
|
.assertNext(receipt -> assertThat(recordedCorrelationId()).isEqualTo("corr-1"))
|
|
.verifyComplete();
|
|
}
|
|
|
|
@Test
|
|
void cancellationAfterCommitDoesNotDeleteNotification() {
|
|
var disposable = reactive().submit(fixture().slowReturnAfterCommit()).subscribe();
|
|
fixture().awaitCommit();
|
|
disposable.dispose();
|
|
assertThat(fixture().requestCount()).isEqualTo(1);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `./gradlew :modules:notification:notification-reactor:test --tests "*.ReactorNotificationOrchestratorTest"`
|
|
|
|
Expected: FAIL: Reactor facade가 없다.
|
|
|
|
- [ ] **Step 3: Write the minimal implementation**
|
|
|
|
Files to implement:
|
|
- `ReactiveNotificationOrchestrator.java`
|
|
- `ReactorNotificationOrchestrator.java`
|
|
- `ReactorContextBridge.java`
|
|
|
|
```java
|
|
public interface ReactiveNotificationOrchestrator {
|
|
reactor.core.publisher.Mono<NotificationReceipt> submit(NotificationPlan plan);
|
|
reactor.core.publisher.Mono<NotificationReceipt> schedule(NotificationPlan plan, java.time.Instant at);
|
|
reactor.core.publisher.Mono<NotificationSnapshot> get(NotificationId id);
|
|
}
|
|
|
|
public Mono<NotificationReceipt> submit(NotificationPlan plan) {
|
|
return Mono.deferContextual(context -> Mono.fromCompletionStage(
|
|
contextBridge.withContext(context, () -> asyncCore.submit(plan))));
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `./gradlew :modules:notification:notification-reactor:test`
|
|
|
|
Expected: PASS: Reactor context와 durable commit cancellation semantics가 검증된다.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add 'modules/notification/notification-reactor/src/main/java/io/backend/skeleton/notification/reactor/ReactiveNotificationOrchestrator.java' 'modules/notification/notification-reactor/src/main/java/io/backend/skeleton/notification/reactor/ReactorNotificationOrchestrator.java' 'modules/notification/notification-reactor/src/main/java/io/backend/skeleton/notification/reactor/ReactorContextBridge.java' 'modules/notification/notification-reactor/src/test/java/io/backend/skeleton/notification/reactor/ReactorNotificationOrchestratorTest.java'
|
|
git commit -m "feat(notification): add Reactor notification facade"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 49: 공통 Provider Contract Testkit·Chaos·Security Suite 구현
|
|
|
|
**Files:**
|
|
- Create: `modules/notification/notification-testkit/src/main/java/io/backend/skeleton/notification/testkit/ProviderAdapterContract.java`
|
|
- Create: `modules/notification/notification-testkit/src/main/java/io/backend/skeleton/notification/testkit/ProviderFaultHarness.java`
|
|
- Create: `modules/notification/notification-testkit/src/main/java/io/backend/skeleton/notification/testkit/CallbackContract.java`
|
|
- Create: `modules/notification/notification-testkit/src/main/java/io/backend/skeleton/notification/testkit/PiiLeakScanner.java`
|
|
- Create: `modules/notification/notification-testkit/src/test/java/io/backend/skeleton/notification/testkit/CrossProviderContractSuiteTest.java`
|
|
- Create: `infra/notification/toxiproxy/docker-compose.yml`
|
|
- Test: `modules/notification/notification-testkit/src/test/java/io/backend/skeleton/notification/testkit/NotificationChaosSecurityTest.java`
|
|
|
|
**Interfaces:**
|
|
- Consumes: Tasks 21-38 provider adapters and callback core.
|
|
- Produces: Reusable confirmed/rejected/ambiguous contract, callback duplicate/order contract, Toxiproxy fault and PII scanner.
|
|
|
|
**Implementation requirements:**
|
|
- 각 abstract test method 본문은 fixture를 호출하고 실제 assertion을 수행한다.
|
|
- 외부 Provider sandbox 없이 PR suite가 결정적으로 실행된다.
|
|
- Toxiproxy suite는 nightly/release job에도 연결한다.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```java
|
|
package io.backend.skeleton.notification.testkit;
|
|
|
|
import org.junit.jupiter.api.Test;
|
|
import static org.assertj.core.api.Assertions.*;
|
|
|
|
class NotificationChaosSecurityTest {
|
|
@Test
|
|
void acceptedThenResponseLossIsAmbiguousForEveryApplicableAdapter() {
|
|
for (var fixture : ProviderFixtures.responseLossCapableAdapters()) {
|
|
var result = fixture.submitAfterServerAcceptsThenDropsResponse();
|
|
assertThat(result.confirmation())
|
|
.as(fixture.providerId())
|
|
.isEqualTo(AttemptConfirmation.AMBIGUOUS);
|
|
}
|
|
}
|
|
|
|
@Test
|
|
void logsMetricsAndTracesContainNoSensitiveFixtureValues() {
|
|
ProviderFixtures.runAllFailurePaths();
|
|
assertThat(PiiLeakScanner.scan(capturedTelemetry(), ProviderFixtures.secrets()))
|
|
.isEmpty();
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `./gradlew :modules:notification:notification-testkit:test --tests "*.NotificationChaosSecurityTest"`
|
|
|
|
Expected: FAIL: cross-provider contract testkit과 fault harness가 없다.
|
|
|
|
- [ ] **Step 3: Write the minimal implementation**
|
|
|
|
Files to implement:
|
|
- `ProviderAdapterContract.java`
|
|
- `ProviderFaultHarness.java`
|
|
- `CallbackContract.java`
|
|
- `PiiLeakScanner.java`
|
|
- `CrossProviderContractSuiteTest.java`
|
|
- `infra/notification/toxiproxy/docker-compose.yml`
|
|
|
|
```java
|
|
public abstract class ProviderAdapterContract {
|
|
protected abstract NotificationProviderAdapter adapter();
|
|
protected abstract ProviderFixture fixture();
|
|
|
|
@Test
|
|
void confirmedAcceptanceMapsOnlyToProviderAccepted() {
|
|
var result = adapter().submit(fixture().confirmedSubmission())
|
|
.toCompletableFuture().join();
|
|
org.assertj.core.api.Assertions.assertThat(result.confirmation())
|
|
.isEqualTo(AttemptConfirmation.CONFIRMED);
|
|
org.assertj.core.api.Assertions.assertThat(result.evidenceLevel())
|
|
.isEqualTo(EvidenceLevel.PROVIDER_ACCEPTED);
|
|
org.assertj.core.api.Assertions.assertThat(result.deliveryOutcome())
|
|
.isEqualTo(DeliveryOutcome.UNKNOWN);
|
|
}
|
|
|
|
@Test
|
|
void explicitRejectionIsNotAmbiguous() {
|
|
var result = adapter().submit(fixture().rejectedSubmission())
|
|
.toCompletableFuture().join();
|
|
org.assertj.core.api.Assertions.assertThat(result.confirmation())
|
|
.isEqualTo(AttemptConfirmation.REJECTED);
|
|
org.assertj.core.api.Assertions.assertThat(result.submissionOutcome())
|
|
.isEqualTo(SubmissionOutcome.CONFIRMED_REJECTED);
|
|
}
|
|
|
|
@Test
|
|
void responseLossAfterCommitIsAmbiguous() {
|
|
var result = adapter().submit(fixture().acceptedThenResponseLostSubmission())
|
|
.toCompletableFuture().join();
|
|
org.assertj.core.api.Assertions.assertThat(result.confirmation())
|
|
.isEqualTo(AttemptConfirmation.AMBIGUOUS);
|
|
org.assertj.core.api.Assertions.assertThat(result.submissionOutcome())
|
|
.isEqualTo(SubmissionOutcome.AMBIGUOUS);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `./gradlew notificationContractTest notificationChaosTest notificationSecurityTest`
|
|
|
|
Expected: PASS: Stable Adapter 공통 contract, ambiguity fault, PII leak scan이 통과한다.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add 'modules/notification/notification-testkit/src/main/java/io/backend/skeleton/notification/testkit/ProviderAdapterContract.java' 'modules/notification/notification-testkit/src/main/java/io/backend/skeleton/notification/testkit/ProviderFaultHarness.java' 'modules/notification/notification-testkit/src/main/java/io/backend/skeleton/notification/testkit/CallbackContract.java' 'modules/notification/notification-testkit/src/main/java/io/backend/skeleton/notification/testkit/PiiLeakScanner.java' 'modules/notification/notification-testkit/src/test/java/io/backend/skeleton/notification/testkit/CrossProviderContractSuiteTest.java' 'infra/notification/toxiproxy/docker-compose.yml' 'modules/notification/notification-testkit/src/test/java/io/backend/skeleton/notification/testkit/NotificationChaosSecurityTest.java'
|
|
git commit -m "test(notification): add cross-provider chaos and security gates"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 50: 성능 인증·호환성 Matrix·문서·Release Gate 완성
|
|
|
|
**Files:**
|
|
- Create: `modules/notification/notification-testkit/src/jmh/java/io/backend/skeleton/notification/testkit/NotificationFanoutBenchmark.java`
|
|
- Create: `modules/notification/notification-testkit/src/performanceTest/java/io/backend/skeleton/notification/testkit/NotificationPerformanceCertificationTest.java`
|
|
- Create: `.github/workflows/notification-platform.yml`
|
|
- Create: `docs/notification/support-matrix.md`
|
|
- Create: `docs/notification/configuration-reference.md`
|
|
- Create: `docs/notification/delivery-evidence.md`
|
|
- Create: `docs/notification/callback-reconciliation.md`
|
|
- Create: `docs/notification/provider-runbooks.md`
|
|
- Create: `docs/notification/security-privacy.md`
|
|
- Create: `docs/notification/operations.md`
|
|
- Create: `docs/notification/migration-guide.md`
|
|
- Create: `docs/notification/adr/NOTIF-ADR-001-durable-acceptance.md`
|
|
- Create: `docs/notification/adr/NOTIF-ADR-002-event-ledger-projection.md`
|
|
- Create: `docs/notification/adr/NOTIF-ADR-003-ambiguous-submission.md`
|
|
- Create: `docs/notification/adr/NOTIF-ADR-004-fcm-fid-primary.md`
|
|
- Test: `modules/notification/notification-testkit/src/test/java/io/backend/skeleton/notification/testkit/NotificationReleaseGateTest.java`
|
|
|
|
**Interfaces:**
|
|
- Consumes: Tasks 1-49의 모든 코드·테스트·설정.
|
|
- Produces: PR/nightly/release workflow, Spring compatibility, bounded resource certification, support docs and ADRs.
|
|
|
|
**Implementation requirements:**
|
|
- 성능 threshold는 측정 후 versioned certification profile에 숫자로 고정한다.
|
|
- Support Matrix는 channel별 최대 evidence와 비지원 보장을 명시한다.
|
|
- Release workflow는 실제 Provider sandbox smoke test를 secret-protected optional gate로 분리한다.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```java
|
|
package io.backend.skeleton.notification.testkit;
|
|
|
|
import org.junit.jupiter.api.Test;
|
|
import java.nio.file.*;
|
|
import static org.assertj.core.api.Assertions.*;
|
|
|
|
class NotificationReleaseGateTest {
|
|
@Test
|
|
void requiredDocumentationAndAdrsExist() {
|
|
assertThat(Path.of("docs/notification/delivery-evidence.md")).exists();
|
|
assertThat(Path.of("docs/notification/callback-reconciliation.md")).exists();
|
|
assertThat(Path.of("docs/notification/adr/NOTIF-ADR-003-ambiguous-submission.md")).exists();
|
|
}
|
|
|
|
@Test
|
|
void supportMatrixDoesNotClaimGuaranteedDelivery() throws Exception {
|
|
var text = Files.readString(Path.of("docs/notification/support-matrix.md"));
|
|
assertThat(text).doesNotContain("exactly once notification", "guaranteed read");
|
|
assertThat(text).contains("PROVIDER_ACCEPTED", "AMBIGUOUS", "FCM_FID");
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `./gradlew :modules:notification:notification-testkit:test --tests "*.NotificationReleaseGateTest"`
|
|
|
|
Expected: FAIL: performance suite, workflow, docs and ADRs가 없다.
|
|
|
|
- [ ] **Step 3: Write the minimal implementation**
|
|
|
|
Files to implement:
|
|
- `NotificationFanoutBenchmark.java`
|
|
- `NotificationPerformanceCertificationTest.java`
|
|
- `.github/workflows/notification-platform.yml`
|
|
- `docs/notification/support-matrix.md`
|
|
- `docs/notification/configuration-reference.md`
|
|
- `docs/notification/delivery-evidence.md`
|
|
- `docs/notification/callback-reconciliation.md`
|
|
- `docs/notification/provider-runbooks.md`
|
|
- `docs/notification/security-privacy.md`
|
|
- `docs/notification/operations.md`
|
|
- `docs/notification/migration-guide.md`
|
|
- `docs/notification/adr/NOTIF-ADR-001-durable-acceptance.md`
|
|
- `docs/notification/adr/NOTIF-ADR-002-event-ledger-projection.md`
|
|
- `docs/notification/adr/NOTIF-ADR-003-ambiguous-submission.md`
|
|
- `docs/notification/adr/NOTIF-ADR-004-fcm-fid-primary.md`
|
|
|
|
```java
|
|
// CI jobs in notification-platform.yml
|
|
// pr: compile, unit, ArchUnit, PostgreSQL contract, provider fixtures, PII scan
|
|
// compatibility: Spring 6.2 latest patch and Spring 7.0 latest patch
|
|
// nightly: Toxiproxy ambiguity, process-kill recovery, callback burst
|
|
// release: performance certification, credential rotation, full support matrix
|
|
|
|
@Test
|
|
void scheduledBurstStaysWithinResourceBounds() {
|
|
var result = harness().runScheduledBurst(100_000);
|
|
assertThat(result.maxHeapBytes()).isLessThan(properties().heapBudgetBytes());
|
|
assertThat(result.maxDbLockWait()).isLessThan(properties().maxDbLockWait());
|
|
assertThat(result.retryAmplification()).isLessThanOrEqualTo(properties().maxRetryAmplification());
|
|
assertThat(result.maxQueueAge()).isLessThan(properties().maxQueueAge());
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `./gradlew clean notificationContractTest notificationChaosTest notificationSecurityTest notificationPerformanceTest notificationCompatibilityTest`
|
|
|
|
Expected: PASS: 전체 build와 contract/chaos/security/performance/compatibility gate가 0 failure로 종료된다.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add 'modules/notification/notification-testkit/src/jmh/java/io/backend/skeleton/notification/testkit/NotificationFanoutBenchmark.java' 'modules/notification/notification-testkit/src/performanceTest/java/io/backend/skeleton/notification/testkit/NotificationPerformanceCertificationTest.java' '.github/workflows/notification-platform.yml' 'docs/notification/support-matrix.md' 'docs/notification/configuration-reference.md' 'docs/notification/delivery-evidence.md' 'docs/notification/callback-reconciliation.md' 'docs/notification/provider-runbooks.md' 'docs/notification/security-privacy.md' 'docs/notification/operations.md' 'docs/notification/migration-guide.md' 'docs/notification/adr/NOTIF-ADR-001-durable-acceptance.md' 'docs/notification/adr/NOTIF-ADR-002-event-ledger-projection.md' 'docs/notification/adr/NOTIF-ADR-003-ambiguous-submission.md' 'docs/notification/adr/NOTIF-ADR-004-fcm-fid-primary.md' 'modules/notification/notification-testkit/src/test/java/io/backend/skeleton/notification/testkit/NotificationReleaseGateTest.java'
|
|
git commit -m "docs(notification): complete release gates and runbooks"
|
|
```
|
|
|
|
---
|
|
|
|
## 4. Final execution order and review gates
|
|
|
|
```text
|
|
Tasks 1-10 Core public contracts and provider SPI
|
|
Tasks 11-20 Error, persistence, submission, scheduler and policy runtime
|
|
Tasks 21-30 Dispatch, callback, reconciliation, attachments, Email and SMS foundation
|
|
Tasks 31-40 Twilio, Mobile Push, Web Push and In-App Inbox
|
|
Tasks 41-48 Dedup/collapse, Webhook, observability, security, Admin, rotation, starter and Reactor
|
|
Tasks 49-50 Cross-provider verification, performance, compatibility and release documentation
|
|
```
|
|
|
|
Every task requires two review gates before moving forward:
|
|
|
|
1. **Specification review:** public signatures, evidence semantics, persistence and failure behavior match the design.
|
|
2. **Quality review:** tests prove red/green behavior, no PII leaks, resource lifecycle and module boundaries are correct.
|
|
|
|
Do not weaken an earlier contract while executing subsequent tasks. When an Adapter cannot prove a common evidence level, keep the lower evidence and expose the difference through ProviderCapabilities.
|