From f303c3a25cf6b709b29926dc1bbedb802aec51ed Mon Sep 17 00:00:00 2001 From: Matthew Van Dusen Date: Tue, 24 Mar 2026 09:42:41 -0500 Subject: [PATCH] Implement simple orchestrated email notification pattern --- README.md | 97 ++++++++++++++- pom.xml | 52 ++++++++ .../notification/NotificationApplication.java | 15 +++ .../config/NotificationCatalog.java | 22 ++++ .../config/NotificationProperties.java | 57 +++++++++ .../notification/domain/NotificationType.java | 6 + .../model/EmailPublicationRequest.java | 10 ++ .../model/HoldListCreatedPayload.java | 8 ++ .../model/HoldListRemovedPayload.java | 8 ++ .../EmailNotificationOrchestrator.java | 76 ++++++++++++ .../HoldListCreatedModelBuilder.java | 30 +++++ .../HoldListRemovedModelBuilder.java | 30 +++++ .../NotificationModelBuilder.java | 14 +++ .../publish/EmailPublicationPublisher.java | 7 ++ .../LoggingEmailPublicationPublisher.java | 18 +++ .../notification/render/TemplateRenderer.java | 7 ++ .../render/ThymeleafTemplateRenderer.java | 24 ++++ .../service/HoldListDomainService.java | 27 +++++ src/main/resources/application.yml | 16 +++ .../templates/hold-list-created.html | 9 ++ .../templates/hold-list-removed.html | 9 ++ .../config/NotificationCatalogTest.java | 37 ++++++ .../EmailNotificationOrchestratorTest.java | 112 ++++++++++++++++++ 23 files changed, 690 insertions(+), 1 deletion(-) create mode 100644 pom.xml create mode 100644 src/main/java/com/example/notification/NotificationApplication.java create mode 100644 src/main/java/com/example/notification/config/NotificationCatalog.java create mode 100644 src/main/java/com/example/notification/config/NotificationProperties.java create mode 100644 src/main/java/com/example/notification/domain/NotificationType.java create mode 100644 src/main/java/com/example/notification/model/EmailPublicationRequest.java create mode 100644 src/main/java/com/example/notification/model/HoldListCreatedPayload.java create mode 100644 src/main/java/com/example/notification/model/HoldListRemovedPayload.java create mode 100644 src/main/java/com/example/notification/orchestration/EmailNotificationOrchestrator.java create mode 100644 src/main/java/com/example/notification/orchestration/HoldListCreatedModelBuilder.java create mode 100644 src/main/java/com/example/notification/orchestration/HoldListRemovedModelBuilder.java create mode 100644 src/main/java/com/example/notification/orchestration/NotificationModelBuilder.java create mode 100644 src/main/java/com/example/notification/publish/EmailPublicationPublisher.java create mode 100644 src/main/java/com/example/notification/publish/LoggingEmailPublicationPublisher.java create mode 100644 src/main/java/com/example/notification/render/TemplateRenderer.java create mode 100644 src/main/java/com/example/notification/render/ThymeleafTemplateRenderer.java create mode 100644 src/main/java/com/example/notification/service/HoldListDomainService.java create mode 100644 src/main/resources/application.yml create mode 100644 src/main/resources/templates/hold-list-created.html create mode 100644 src/main/resources/templates/hold-list-removed.html create mode 100644 src/test/java/com/example/notification/config/NotificationCatalogTest.java create mode 100644 src/test/java/com/example/notification/orchestration/EmailNotificationOrchestratorTest.java diff --git a/README.md b/README.md index a0054b0..7a29d49 100644 --- a/README.md +++ b/README.md @@ -1 +1,96 @@ -# nginx-remote-app-poc \ No newline at end of file +# Spring Boot Email Notification Pattern (Publish-Only) + +This project demonstrates a **boring, low-cognitive-load** notification pattern: + +- one orchestration flow for render + publish +- one config catalog for YAML lookup +- one builder per notification type for payload-to-template-model mapping +- shared renderer and shared publisher interfaces + +The service **does not send email directly**. It only creates and publishes an `EmailPublicationRequest` to another delivery service. + +## Why this pattern keeps cognitive load low + +- You call a single entry point (`EmailNotificationOrchestrator.notify(...)`). +- Shared concerns (config lookup, rendering, publishing, enabled checks) live in one place. +- Notification-specific code stays tiny and local (just model builders + optional payload records). +- No giant switch in domain services and no ad hoc per-notification pipelines. + +## Package structure + +```text +src/main/java/com/example/notification +├── NotificationApplication.java +├── config +│ ├── NotificationCatalog.java +│ └── NotificationProperties.java +├── domain +│ └── NotificationType.java +├── model +│ ├── EmailPublicationRequest.java +│ ├── HoldListCreatedPayload.java +│ └── HoldListRemovedPayload.java +├── orchestration +│ ├── EmailNotificationOrchestrator.java +│ ├── HoldListCreatedModelBuilder.java +│ ├── HoldListRemovedModelBuilder.java +│ └── NotificationModelBuilder.java +├── publish +│ ├── EmailPublicationPublisher.java +│ └── LoggingEmailPublicationPublisher.java +├── render +│ ├── TemplateRenderer.java +│ └── ThymeleafTemplateRenderer.java +└── service + └── HoldListDomainService.java +``` + +## Runtime flow + +1. Domain service calls orchestrator `notify(type, payload)`. +2. Orchestrator loads config from `NotificationCatalog`. +3. Orchestrator resolves builder for `NotificationType`. +4. Builder converts payload -> template model. +5. Shared `TemplateRenderer` renders HTML. +6. Orchestrator builds `EmailPublicationRequest`. +7. Shared `EmailPublicationPublisher` publishes it. + +## Configuration example + +See `src/main/resources/application.yml`. + +## Template examples + +See: + +- `src/main/resources/templates/hold-list-created.html` +- `src/main/resources/templates/hold-list-removed.html` + +## Example usage + +See `HoldListDomainService` for how business logic triggers notifications through one orchestrator entry point. + +## How to add a new notification type + +1. Add a new enum value in `NotificationType`. +2. Add YAML config under `notifications.email.` in `application.yml`. +3. Add template file in `resources/templates`. +4. Add payload record (optional but recommended for type clarity). +5. Add one `NotificationModelBuilder` component for the new type. +6. Start using `orchestrator.notify(NEW_TYPE, payload)` from domain service. + +No shared orchestration changes should be needed. + +## Focused tests + +- `NotificationCatalogTest`: config lookup + missing config behavior. +- `EmailNotificationOrchestratorTest`: happy path publish, disabled behavior, payload mismatch guard. + +## Tradeoffs + +This pattern intentionally prefers explicit wiring over dynamic magic: + +- **Pros:** easy to trace, easy to debug, simple onboarding, low day-2 maintenance. +- **Cons:** one small builder class per notification type (a bit more boilerplate). + +Given maintainability goals, this is a good trade. diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..69ad106 --- /dev/null +++ b/pom.xml @@ -0,0 +1,52 @@ + + 4.0.0 + + com.example + notification-service-pattern + 1.0.0-SNAPSHOT + notification-service-pattern + + + org.springframework.boot + spring-boot-starter-parent + 3.3.5 + + + + + 17 + + + + + org.springframework.boot + spring-boot-starter + + + org.springframework.boot + spring-boot-starter-thymeleaf + + + org.springframework.boot + spring-boot-configuration-processor + true + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/src/main/java/com/example/notification/NotificationApplication.java b/src/main/java/com/example/notification/NotificationApplication.java new file mode 100644 index 0000000..767dca5 --- /dev/null +++ b/src/main/java/com/example/notification/NotificationApplication.java @@ -0,0 +1,15 @@ +package com.example.notification; + +import com.example.notification.config.NotificationProperties; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; + +@SpringBootApplication +@EnableConfigurationProperties(NotificationProperties.class) +public class NotificationApplication { + + public static void main(String[] args) { + SpringApplication.run(NotificationApplication.class, args); + } +} diff --git a/src/main/java/com/example/notification/config/NotificationCatalog.java b/src/main/java/com/example/notification/config/NotificationCatalog.java new file mode 100644 index 0000000..09e0eac --- /dev/null +++ b/src/main/java/com/example/notification/config/NotificationCatalog.java @@ -0,0 +1,22 @@ +package com.example.notification.config; + +import com.example.notification.domain.NotificationType; +import org.springframework.stereotype.Component; + +@Component +public class NotificationCatalog { + + private final NotificationProperties properties; + + public NotificationCatalog(NotificationProperties properties) { + this.properties = properties; + } + + public NotificationProperties.NotificationDefinition getRequired(NotificationType type) { + NotificationProperties.NotificationDefinition definition = properties.getEmail().get(type); + if (definition == null) { + throw new IllegalArgumentException("No notification config found for type: " + type); + } + return definition; + } +} diff --git a/src/main/java/com/example/notification/config/NotificationProperties.java b/src/main/java/com/example/notification/config/NotificationProperties.java new file mode 100644 index 0000000..48bec98 --- /dev/null +++ b/src/main/java/com/example/notification/config/NotificationProperties.java @@ -0,0 +1,57 @@ +package com.example.notification.config; + +import com.example.notification.domain.NotificationType; +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.EnumMap; +import java.util.List; +import java.util.Map; + +@ConfigurationProperties(prefix = "notifications") +public class NotificationProperties { + + private final Map email = new EnumMap<>(NotificationType.class); + + public Map getEmail() { + return email; + } + + public static class NotificationDefinition { + private List recipients; + private String templatePath; + private String subject; + private boolean enabled = true; + + public List getRecipients() { + return recipients; + } + + public void setRecipients(List recipients) { + this.recipients = recipients; + } + + public String getTemplatePath() { + return templatePath; + } + + public void setTemplatePath(String templatePath) { + this.templatePath = templatePath; + } + + public String getSubject() { + return subject; + } + + public void setSubject(String subject) { + this.subject = subject; + } + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + } +} diff --git a/src/main/java/com/example/notification/domain/NotificationType.java b/src/main/java/com/example/notification/domain/NotificationType.java new file mode 100644 index 0000000..bb68ab7 --- /dev/null +++ b/src/main/java/com/example/notification/domain/NotificationType.java @@ -0,0 +1,6 @@ +package com.example.notification.domain; + +public enum NotificationType { + HOLD_LIST_CREATED, + HOLD_LIST_REMOVED +} diff --git a/src/main/java/com/example/notification/model/EmailPublicationRequest.java b/src/main/java/com/example/notification/model/EmailPublicationRequest.java new file mode 100644 index 0000000..f53a71e --- /dev/null +++ b/src/main/java/com/example/notification/model/EmailPublicationRequest.java @@ -0,0 +1,10 @@ +package com.example.notification.model; + +import java.util.List; + +public record EmailPublicationRequest( + List recipients, + String subject, + String htmlBody +) { +} diff --git a/src/main/java/com/example/notification/model/HoldListCreatedPayload.java b/src/main/java/com/example/notification/model/HoldListCreatedPayload.java new file mode 100644 index 0000000..aca9496 --- /dev/null +++ b/src/main/java/com/example/notification/model/HoldListCreatedPayload.java @@ -0,0 +1,8 @@ +package com.example.notification.model; + +public record HoldListCreatedPayload( + String holdListId, + String createdBy, + int itemCount +) { +} diff --git a/src/main/java/com/example/notification/model/HoldListRemovedPayload.java b/src/main/java/com/example/notification/model/HoldListRemovedPayload.java new file mode 100644 index 0000000..a5a0cb0 --- /dev/null +++ b/src/main/java/com/example/notification/model/HoldListRemovedPayload.java @@ -0,0 +1,8 @@ +package com.example.notification.model; + +public record HoldListRemovedPayload( + String holdListId, + String removedBy, + String reason +) { +} diff --git a/src/main/java/com/example/notification/orchestration/EmailNotificationOrchestrator.java b/src/main/java/com/example/notification/orchestration/EmailNotificationOrchestrator.java new file mode 100644 index 0000000..d60019c --- /dev/null +++ b/src/main/java/com/example/notification/orchestration/EmailNotificationOrchestrator.java @@ -0,0 +1,76 @@ +package com.example.notification.orchestration; + +import com.example.notification.config.NotificationCatalog; +import com.example.notification.config.NotificationProperties; +import com.example.notification.domain.NotificationType; +import com.example.notification.model.EmailPublicationRequest; +import com.example.notification.publish.EmailPublicationPublisher; +import com.example.notification.render.TemplateRenderer; +import org.springframework.stereotype.Service; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Service +public class EmailNotificationOrchestrator { + + private final NotificationCatalog catalog; + private final TemplateRenderer templateRenderer; + private final EmailPublicationPublisher publisher; + private final Map> builders; + + public EmailNotificationOrchestrator(NotificationCatalog catalog, + TemplateRenderer templateRenderer, + EmailPublicationPublisher publisher, + List> modelBuilders) { + this.catalog = catalog; + this.templateRenderer = templateRenderer; + this.publisher = publisher; + this.builders = indexByType(modelBuilders); + } + + public void notify(NotificationType type, T payload) { + NotificationProperties.NotificationDefinition definition = catalog.getRequired(type); + if (!definition.isEnabled()) { + return; + } + + NotificationModelBuilder builder = getBuilder(type, payload); + Map model = builder.buildModel(payload); + String htmlBody = templateRenderer.render(definition.getTemplatePath(), model); + + EmailPublicationRequest request = new EmailPublicationRequest( + definition.getRecipients(), + definition.getSubject(), + htmlBody + ); + publisher.publish(request); + } + + private Map> indexByType(List> modelBuilders) { + Map> indexed = new HashMap<>(); + for (NotificationModelBuilder builder : modelBuilders) { + NotificationModelBuilder previous = indexed.put(builder.supports(), builder); + if (previous != null) { + throw new IllegalStateException("Duplicate builder for type: " + builder.supports()); + } + } + return indexed; + } + + @SuppressWarnings("unchecked") + private NotificationModelBuilder getBuilder(NotificationType type, T payload) { + NotificationModelBuilder candidate = builders.get(type); + if (candidate == null) { + throw new IllegalArgumentException("No model builder found for type: " + type); + } + if (!candidate.payloadType().isInstance(payload)) { + throw new IllegalArgumentException( + "Payload type mismatch for " + type + ". Expected " + + candidate.payloadType().getSimpleName() + " but got " + payload.getClass().getSimpleName() + ); + } + return (NotificationModelBuilder) candidate; + } +} diff --git a/src/main/java/com/example/notification/orchestration/HoldListCreatedModelBuilder.java b/src/main/java/com/example/notification/orchestration/HoldListCreatedModelBuilder.java new file mode 100644 index 0000000..32c9667 --- /dev/null +++ b/src/main/java/com/example/notification/orchestration/HoldListCreatedModelBuilder.java @@ -0,0 +1,30 @@ +package com.example.notification.orchestration; + +import com.example.notification.domain.NotificationType; +import com.example.notification.model.HoldListCreatedPayload; +import org.springframework.stereotype.Component; + +import java.util.Map; + +@Component +public class HoldListCreatedModelBuilder implements NotificationModelBuilder { + + @Override + public NotificationType supports() { + return NotificationType.HOLD_LIST_CREATED; + } + + @Override + public Class payloadType() { + return HoldListCreatedPayload.class; + } + + @Override + public Map buildModel(HoldListCreatedPayload payload) { + return Map.of( + "holdListId", payload.holdListId(), + "createdBy", payload.createdBy(), + "itemCount", payload.itemCount() + ); + } +} diff --git a/src/main/java/com/example/notification/orchestration/HoldListRemovedModelBuilder.java b/src/main/java/com/example/notification/orchestration/HoldListRemovedModelBuilder.java new file mode 100644 index 0000000..3e7ffe8 --- /dev/null +++ b/src/main/java/com/example/notification/orchestration/HoldListRemovedModelBuilder.java @@ -0,0 +1,30 @@ +package com.example.notification.orchestration; + +import com.example.notification.domain.NotificationType; +import com.example.notification.model.HoldListRemovedPayload; +import org.springframework.stereotype.Component; + +import java.util.Map; + +@Component +public class HoldListRemovedModelBuilder implements NotificationModelBuilder { + + @Override + public NotificationType supports() { + return NotificationType.HOLD_LIST_REMOVED; + } + + @Override + public Class payloadType() { + return HoldListRemovedPayload.class; + } + + @Override + public Map buildModel(HoldListRemovedPayload payload) { + return Map.of( + "holdListId", payload.holdListId(), + "removedBy", payload.removedBy(), + "reason", payload.reason() + ); + } +} diff --git a/src/main/java/com/example/notification/orchestration/NotificationModelBuilder.java b/src/main/java/com/example/notification/orchestration/NotificationModelBuilder.java new file mode 100644 index 0000000..9f6ed53 --- /dev/null +++ b/src/main/java/com/example/notification/orchestration/NotificationModelBuilder.java @@ -0,0 +1,14 @@ +package com.example.notification.orchestration; + +import com.example.notification.domain.NotificationType; + +import java.util.Map; + +public interface NotificationModelBuilder { + + NotificationType supports(); + + Class payloadType(); + + Map buildModel(T payload); +} diff --git a/src/main/java/com/example/notification/publish/EmailPublicationPublisher.java b/src/main/java/com/example/notification/publish/EmailPublicationPublisher.java new file mode 100644 index 0000000..28f4837 --- /dev/null +++ b/src/main/java/com/example/notification/publish/EmailPublicationPublisher.java @@ -0,0 +1,7 @@ +package com.example.notification.publish; + +import com.example.notification.model.EmailPublicationRequest; + +public interface EmailPublicationPublisher { + void publish(EmailPublicationRequest request); +} diff --git a/src/main/java/com/example/notification/publish/LoggingEmailPublicationPublisher.java b/src/main/java/com/example/notification/publish/LoggingEmailPublicationPublisher.java new file mode 100644 index 0000000..7203534 --- /dev/null +++ b/src/main/java/com/example/notification/publish/LoggingEmailPublicationPublisher.java @@ -0,0 +1,18 @@ +package com.example.notification.publish; + +import com.example.notification.model.EmailPublicationRequest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +@Component +public class LoggingEmailPublicationPublisher implements EmailPublicationPublisher { + + private static final Logger log = LoggerFactory.getLogger(LoggingEmailPublicationPublisher.class); + + @Override + public void publish(EmailPublicationRequest request) { + // Placeholder implementation. Replace with Kafka/topic producer adapter in real deployment. + log.info("Publishing email request: recipients={}, subject={}", request.recipients(), request.subject()); + } +} diff --git a/src/main/java/com/example/notification/render/TemplateRenderer.java b/src/main/java/com/example/notification/render/TemplateRenderer.java new file mode 100644 index 0000000..753126c --- /dev/null +++ b/src/main/java/com/example/notification/render/TemplateRenderer.java @@ -0,0 +1,7 @@ +package com.example.notification.render; + +import java.util.Map; + +public interface TemplateRenderer { + String render(String templatePath, Map model); +} diff --git a/src/main/java/com/example/notification/render/ThymeleafTemplateRenderer.java b/src/main/java/com/example/notification/render/ThymeleafTemplateRenderer.java new file mode 100644 index 0000000..4ece2c5 --- /dev/null +++ b/src/main/java/com/example/notification/render/ThymeleafTemplateRenderer.java @@ -0,0 +1,24 @@ +package com.example.notification.render; + +import org.springframework.stereotype.Component; +import org.thymeleaf.TemplateEngine; +import org.thymeleaf.context.Context; + +import java.util.Map; + +@Component +public class ThymeleafTemplateRenderer implements TemplateRenderer { + + private final TemplateEngine templateEngine; + + public ThymeleafTemplateRenderer(TemplateEngine templateEngine) { + this.templateEngine = templateEngine; + } + + @Override + public String render(String templatePath, Map model) { + Context context = new Context(); + context.setVariables(model); + return templateEngine.process(templatePath, context); + } +} diff --git a/src/main/java/com/example/notification/service/HoldListDomainService.java b/src/main/java/com/example/notification/service/HoldListDomainService.java new file mode 100644 index 0000000..19ecd3f --- /dev/null +++ b/src/main/java/com/example/notification/service/HoldListDomainService.java @@ -0,0 +1,27 @@ +package com.example.notification.service; + +import com.example.notification.domain.NotificationType; +import com.example.notification.model.HoldListCreatedPayload; +import com.example.notification.model.HoldListRemovedPayload; +import com.example.notification.orchestration.EmailNotificationOrchestrator; +import org.springframework.stereotype.Service; + +@Service +public class HoldListDomainService { + + private final EmailNotificationOrchestrator notificationOrchestrator; + + public HoldListDomainService(EmailNotificationOrchestrator notificationOrchestrator) { + this.notificationOrchestrator = notificationOrchestrator; + } + + public void createHoldList(String holdListId, String actor, int itemCount) { + HoldListCreatedPayload payload = new HoldListCreatedPayload(holdListId, actor, itemCount); + notificationOrchestrator.notify(NotificationType.HOLD_LIST_CREATED, payload); + } + + public void removeHoldList(String holdListId, String actor, String reason) { + HoldListRemovedPayload payload = new HoldListRemovedPayload(holdListId, actor, reason); + notificationOrchestrator.notify(NotificationType.HOLD_LIST_REMOVED, payload); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..646226d --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,16 @@ +notifications: + email: + HOLD_LIST_CREATED: + recipients: + - hold-ops@example.com + - support@example.com + subject: "Hold list created" + template-path: "hold-list-created" + enabled: true + + HOLD_LIST_REMOVED: + recipients: + - hold-ops@example.com + subject: "Hold list removed" + template-path: "hold-list-removed" + enabled: true diff --git a/src/main/resources/templates/hold-list-created.html b/src/main/resources/templates/hold-list-created.html new file mode 100644 index 0000000..d7a1cff --- /dev/null +++ b/src/main/resources/templates/hold-list-created.html @@ -0,0 +1,9 @@ + + + +

A hold list was created.

+

Hold list ID:

+

Created by:

+

Items:

+ + diff --git a/src/main/resources/templates/hold-list-removed.html b/src/main/resources/templates/hold-list-removed.html new file mode 100644 index 0000000..c377933 --- /dev/null +++ b/src/main/resources/templates/hold-list-removed.html @@ -0,0 +1,9 @@ + + + +

A hold list was removed.

+

Hold list ID:

+

Removed by:

+

Reason:

+ + diff --git a/src/test/java/com/example/notification/config/NotificationCatalogTest.java b/src/test/java/com/example/notification/config/NotificationCatalogTest.java new file mode 100644 index 0000000..bf484e2 --- /dev/null +++ b/src/test/java/com/example/notification/config/NotificationCatalogTest.java @@ -0,0 +1,37 @@ +package com.example.notification.config; + +import com.example.notification.domain.NotificationType; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class NotificationCatalogTest { + + @Test + void shouldReturnConfiguredDefinition() { + NotificationProperties properties = new NotificationProperties(); + NotificationProperties.NotificationDefinition definition = new NotificationProperties.NotificationDefinition(); + definition.setRecipients(List.of("ops@example.com")); + definition.setSubject("Hold list created"); + definition.setTemplatePath("hold-list-created"); + properties.getEmail().put(NotificationType.HOLD_LIST_CREATED, definition); + + NotificationCatalog catalog = new NotificationCatalog(properties); + + NotificationProperties.NotificationDefinition found = catalog.getRequired(NotificationType.HOLD_LIST_CREATED); + + assertEquals("hold-list-created", found.getTemplatePath()); + assertEquals("Hold list created", found.getSubject()); + } + + @Test + void shouldThrowWhenTypeIsMissing() { + NotificationCatalog catalog = new NotificationCatalog(new NotificationProperties()); + + assertThrows(IllegalArgumentException.class, + () -> catalog.getRequired(NotificationType.HOLD_LIST_REMOVED)); + } +} diff --git a/src/test/java/com/example/notification/orchestration/EmailNotificationOrchestratorTest.java b/src/test/java/com/example/notification/orchestration/EmailNotificationOrchestratorTest.java new file mode 100644 index 0000000..fa0f79f --- /dev/null +++ b/src/test/java/com/example/notification/orchestration/EmailNotificationOrchestratorTest.java @@ -0,0 +1,112 @@ +package com.example.notification.orchestration; + +import com.example.notification.config.NotificationCatalog; +import com.example.notification.config.NotificationProperties; +import com.example.notification.domain.NotificationType; +import com.example.notification.model.EmailPublicationRequest; +import com.example.notification.model.HoldListCreatedPayload; +import com.example.notification.publish.EmailPublicationPublisher; +import com.example.notification.render.TemplateRenderer; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class EmailNotificationOrchestratorTest { + + @Test + void shouldRenderAndPublishWhenEnabled() { + NotificationProperties properties = new NotificationProperties(); + NotificationProperties.NotificationDefinition definition = new NotificationProperties.NotificationDefinition(); + definition.setRecipients(List.of("ops@example.com")); + definition.setSubject("Hold list created"); + definition.setTemplatePath("hold-list-created"); + properties.getEmail().put(NotificationType.HOLD_LIST_CREATED, definition); + + FakeTemplateRenderer renderer = new FakeTemplateRenderer(); + FakePublisher publisher = new FakePublisher(); + + EmailNotificationOrchestrator orchestrator = new EmailNotificationOrchestrator( + new NotificationCatalog(properties), + renderer, + publisher, + List.of(new HoldListCreatedModelBuilder()) + ); + + HoldListCreatedPayload payload = new HoldListCreatedPayload("HL-123", "alice", 4); + orchestrator.notify(NotificationType.HOLD_LIST_CREATED, payload); + + assertEquals("hold-list-created", renderer.templatePath); + assertEquals("HL-123", renderer.model.get("holdListId")); + assertEquals(1, publisher.published.size()); + assertEquals("Hold list created", publisher.published.getFirst().subject()); + } + + @Test + void shouldNotPublishWhenDisabled() { + NotificationProperties properties = new NotificationProperties(); + NotificationProperties.NotificationDefinition definition = new NotificationProperties.NotificationDefinition(); + definition.setRecipients(List.of("ops@example.com")); + definition.setSubject("Hold list created"); + definition.setTemplatePath("hold-list-created"); + definition.setEnabled(false); + properties.getEmail().put(NotificationType.HOLD_LIST_CREATED, definition); + + FakePublisher publisher = new FakePublisher(); + EmailNotificationOrchestrator orchestrator = new EmailNotificationOrchestrator( + new NotificationCatalog(properties), + (templatePath, model) -> "ignored", + publisher, + List.of(new HoldListCreatedModelBuilder()) + ); + + orchestrator.notify(NotificationType.HOLD_LIST_CREATED, new HoldListCreatedPayload("HL-123", "alice", 4)); + + assertEquals(0, publisher.published.size()); + } + + @Test + void shouldFailFastOnPayloadTypeMismatch() { + NotificationProperties properties = new NotificationProperties(); + NotificationProperties.NotificationDefinition definition = new NotificationProperties.NotificationDefinition(); + definition.setRecipients(List.of("ops@example.com")); + definition.setSubject("Hold list created"); + definition.setTemplatePath("hold-list-created"); + properties.getEmail().put(NotificationType.HOLD_LIST_CREATED, definition); + + EmailNotificationOrchestrator orchestrator = new EmailNotificationOrchestrator( + new NotificationCatalog(properties), + (templatePath, model) -> "ignored", + request -> { }, + List.of(new HoldListCreatedModelBuilder()) + ); + + assertThrows(IllegalArgumentException.class, + () -> orchestrator.notify(NotificationType.HOLD_LIST_CREATED, "wrong payload")); + } + + private static class FakeTemplateRenderer implements TemplateRenderer { + private String templatePath; + private Map model; + + @Override + public String render(String templatePath, Map model) { + this.templatePath = templatePath; + this.model = model; + return "ok"; + } + } + + private static class FakePublisher implements EmailPublicationPublisher { + private final List published = new ArrayList<>(); + + @Override + public void publish(EmailPublicationRequest request) { + published.add(request); + } + } +}