feat(notifications): TODO 7 — draft decision toast + email + bell badge
This commit is contained in:
+375
@@ -0,0 +1,375 @@
|
||||
package cloud.nstart.terravault.ordinis.notifications.dispatcher;
|
||||
|
||||
import cloud.nstart.terravault.ordinis.domain.notification.NotificationLog;
|
||||
import cloud.nstart.terravault.ordinis.domain.notification.NotificationLogRepository;
|
||||
import cloud.nstart.terravault.ordinis.domain.outbox.OutboxEvent;
|
||||
import cloud.nstart.terravault.ordinis.domain.outbox.OutboxEventRepository;
|
||||
import cloud.nstart.terravault.ordinis.notifications.channel.ChannelKind;
|
||||
import cloud.nstart.terravault.ordinis.notifications.channel.NotificationChannel;
|
||||
import cloud.nstart.terravault.ordinis.notifications.channel.NotificationChannel.DispatchResult;
|
||||
import cloud.nstart.terravault.ordinis.notifications.channel.RenderedMessage;
|
||||
import cloud.nstart.terravault.ordinis.notifications.channel.UserRef;
|
||||
import cloud.nstart.terravault.ordinis.notifications.event.NotificationEvent;
|
||||
import cloud.nstart.terravault.ordinis.notifications.template.MessageRenderer;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import io.micrometer.core.instrument.Counter;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Notifications dispatcher — polls outbox_events for draft lifecycle events and
|
||||
* fans out email notifications to the relevant recipients.
|
||||
*
|
||||
* <p>Mirrors the pattern of {@code WebhookDispatcher} (two @Scheduled ticks):
|
||||
* <ol>
|
||||
* <li><b>matchTick</b>: scans recent outbox_events for draft decision event
|
||||
* types not yet processed by this dispatcher.</li>
|
||||
* <li><b>sendTick</b>: for each matched event, resolves recipient(s), renders
|
||||
* via {@code MessageRenderer}, claims via {@code IdempotencyGuard}, dispatches
|
||||
* via enabled channels.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>Recipient resolution:
|
||||
* <ul>
|
||||
* <li>{@code RecordDraftApproved / RecordDraftRejected / RecordDraftWithdrawn}:
|
||||
* notify the maker (maker_id from payload → look up email in UserDisplayService).</li>
|
||||
* <li>{@code RecordDraftSubmitted}: notify a configurable reviewer pool address
|
||||
* ({@code ordinis.notifications.reviewer-pool-email}). Skip silently if not set.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Idempotency: per-recipient claim via {@code IdempotencyGuard} (INSERT ON CONFLICT DO NOTHING).
|
||||
* The dispatcher reads events regardless of their {@code published_at} state — it does not require
|
||||
* Kafka publication to have completed (same pattern as WebhookDispatcher).
|
||||
*
|
||||
* <p>Activated only when {@code ordinis.notifications.enabled=true}.
|
||||
*/
|
||||
@Component
|
||||
@ConditionalOnProperty(name = "ordinis.notifications.enabled", havingValue = "true", matchIfMissing = false)
|
||||
public class NotificationsDispatcher {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(NotificationsDispatcher.class);
|
||||
|
||||
private static final Set<String> DRAFT_EVENT_TYPES = Set.of(
|
||||
"RecordDraftApproved",
|
||||
"RecordDraftRejected",
|
||||
"RecordDraftWithdrawn",
|
||||
"RecordDraftSubmitted"
|
||||
);
|
||||
|
||||
private final OutboxEventRepository outboxRepository;
|
||||
private final NotificationLogRepository notificationLogRepository;
|
||||
private final IdempotencyGuard idempotencyGuard;
|
||||
private final MessageRenderer messageRenderer;
|
||||
private final List<NotificationChannel> channels;
|
||||
private final RecipientResolver recipientResolver;
|
||||
|
||||
private final Counter sentCounter;
|
||||
private final Counter failedCounter;
|
||||
private final Counter skippedCounter;
|
||||
|
||||
@Value("${ordinis.notifications.poll-interval-ms:30000}")
|
||||
private long pollIntervalMs;
|
||||
|
||||
@Value("${ordinis.notifications.batch-size:50}")
|
||||
private int batchSize;
|
||||
|
||||
@Value("${ordinis.notifications.reviewer-pool-email:}")
|
||||
private String reviewerPoolEmail;
|
||||
|
||||
@Value("${ordinis.notifications.base-url:${ordinis.notifications.email.base-url:}}")
|
||||
private String baseUrl;
|
||||
|
||||
public NotificationsDispatcher(
|
||||
OutboxEventRepository outboxRepository,
|
||||
NotificationLogRepository notificationLogRepository,
|
||||
IdempotencyGuard idempotencyGuard,
|
||||
MessageRenderer messageRenderer,
|
||||
List<NotificationChannel> channels,
|
||||
RecipientResolver recipientResolver,
|
||||
MeterRegistry meterRegistry) {
|
||||
this.outboxRepository = outboxRepository;
|
||||
this.notificationLogRepository = notificationLogRepository;
|
||||
this.idempotencyGuard = idempotencyGuard;
|
||||
this.messageRenderer = messageRenderer;
|
||||
this.channels = channels;
|
||||
this.recipientResolver = recipientResolver;
|
||||
|
||||
this.sentCounter = Counter.builder("nsi_notification_sent_total")
|
||||
.description("Total notifications successfully sent")
|
||||
.register(meterRegistry);
|
||||
this.failedCounter = Counter.builder("nsi_notification_failed_total")
|
||||
.description("Total notification send failures")
|
||||
.register(meterRegistry);
|
||||
this.skippedCounter = Counter.builder("nsi_notification_skipped_total")
|
||||
.description("Total notifications skipped (idempotency hit or no recipient)")
|
||||
.register(meterRegistry);
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
void init() {
|
||||
log.info("NotificationsDispatcher initialized. Channels: {}, reviewerPoolEmail configured: {}",
|
||||
channels.stream().map(c -> c.kind().wireName()).toList(),
|
||||
!reviewerPoolEmail.isBlank());
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan recent outbox events for draft lifecycle types and process each.
|
||||
* Runs every {@code ordinis.notifications.poll-interval-ms} ms.
|
||||
*/
|
||||
@Scheduled(fixedDelayString = "${ordinis.notifications.poll-interval-ms:30000}")
|
||||
@Transactional
|
||||
public void sendTick() {
|
||||
List<OutboxEvent> events = outboxRepository.findUnpublished(PageRequest.of(0, batchSize));
|
||||
if (events.isEmpty()) return;
|
||||
|
||||
List<OutboxEvent> drafts = events.stream()
|
||||
.filter(e -> DRAFT_EVENT_TYPES.contains(e.getEventType()))
|
||||
.toList();
|
||||
|
||||
if (drafts.isEmpty()) return;
|
||||
log.debug("NotificationsDispatcher sendTick: {} draft events to process", drafts.size());
|
||||
|
||||
for (OutboxEvent event : drafts) {
|
||||
processEvent(event);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single outbox event: resolve recipient(s), render, claim, dispatch.
|
||||
* Errors per event are caught so one bad event does not block others.
|
||||
*/
|
||||
void processEvent(OutboxEvent event) {
|
||||
try {
|
||||
JsonNode payload = event.getPayload();
|
||||
String eventType = event.getEventType();
|
||||
UUID eventUuid = extractEventUuid(payload, event);
|
||||
|
||||
NotificationEvent notifEvent = buildNotificationEvent(event, eventUuid, payload);
|
||||
List<UserRef> recipients = resolveRecipients(eventType, payload);
|
||||
|
||||
if (recipients.isEmpty()) {
|
||||
log.debug("No recipients for event {} ({}). Skipping.", event.getId(), eventType);
|
||||
skippedCounter.increment();
|
||||
return;
|
||||
}
|
||||
|
||||
for (UserRef recipient : recipients) {
|
||||
dispatchToRecipient(notifEvent, recipient, eventType);
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
log.warn("Failed to process outbox event id={} type={}: {}",
|
||||
event.getId(), event.getEventType(), ex.getMessage(), ex);
|
||||
failedCounter.increment();
|
||||
}
|
||||
}
|
||||
|
||||
private void dispatchToRecipient(NotificationEvent notifEvent, UserRef recipient, String eventType) {
|
||||
for (NotificationChannel channel : channels) {
|
||||
if (!channel.isEnabled()) continue;
|
||||
|
||||
String recipientId = recipient.recipientFor(channel.kind());
|
||||
if (recipientId == null || recipientId.isBlank()) {
|
||||
skippedCounter.increment();
|
||||
continue;
|
||||
}
|
||||
|
||||
Optional<NotificationLog> claimed = idempotencyGuard.tryClaim(
|
||||
notifEvent, channel.kind(), recipientId);
|
||||
|
||||
if (claimed.isEmpty()) {
|
||||
skippedCounter.increment();
|
||||
continue;
|
||||
}
|
||||
|
||||
Long rowId = claimed.get().getId();
|
||||
try {
|
||||
String eventKey = toEventKey(eventType);
|
||||
RenderedMessage msg = messageRenderer.render(
|
||||
eventKey, channel.kind(), recipient.locale(),
|
||||
buildParams(notifEvent, channel.kind()));
|
||||
|
||||
DispatchResult result = channel.dispatch(notifEvent, recipient, msg);
|
||||
|
||||
if (result.ok()) {
|
||||
idempotencyGuard.markSent(rowId);
|
||||
sentCounter.increment();
|
||||
log.debug("Notification sent: event={} channel={} recipient={}",
|
||||
notifEvent.eventId(), channel.kind().wireName(), recipientId);
|
||||
} else {
|
||||
idempotencyGuard.markFailed(rowId, result.errorMsg());
|
||||
failedCounter.increment();
|
||||
log.warn("Notification failed: event={} channel={} recipient={} error={}",
|
||||
notifEvent.eventId(), channel.kind().wireName(), recipientId, result.errorMsg());
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
idempotencyGuard.markFailed(rowId, ex.getClass().getSimpleName() + ": " + ex.getMessage());
|
||||
failedCounter.increment();
|
||||
log.warn("Notification dispatch error: event={} channel={} recipient={}",
|
||||
notifEvent.eventId(), channel.kind().wireName(), recipientId, ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve notification recipients based on event type.
|
||||
* <ul>
|
||||
* <li>Approved / Rejected: notify maker (draft decision sent back to maker).</li>
|
||||
* <li>Withdrawn: notify reviewer pool (queue cleanup ping).</li>
|
||||
* <li>Submitted: notify reviewer pool (new item in queue).</li>
|
||||
* </ul>
|
||||
*/
|
||||
private List<UserRef> resolveRecipients(String eventType, JsonNode payload) {
|
||||
return switch (eventType) {
|
||||
case "RecordDraftApproved", "RecordDraftRejected" -> resolveMaker(payload);
|
||||
case "RecordDraftWithdrawn", "RecordDraftSubmitted" -> resolveReviewerPool();
|
||||
default -> List.of();
|
||||
};
|
||||
}
|
||||
|
||||
private List<UserRef> resolveMaker(JsonNode payload) {
|
||||
String makerId = stringField(payload, "makerId");
|
||||
if (makerId == null) return List.of();
|
||||
return recipientResolver.resolveById(makerId)
|
||||
.map(List::of)
|
||||
.orElse(List.of());
|
||||
}
|
||||
|
||||
private List<UserRef> resolveReviewerPool() {
|
||||
if (reviewerPoolEmail.isBlank()) return List.of();
|
||||
UserRef poolRef = new UserRef("reviewer-pool", reviewerPoolEmail, null, null, Locale.forLanguageTag("ru"));
|
||||
return List.of(poolRef);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map outbox event type string to notification template event key.
|
||||
*/
|
||||
static String toEventKey(String eventType) {
|
||||
return switch (eventType) {
|
||||
case "RecordDraftApproved", "RecordDraftRejected" -> "draft_decision";
|
||||
case "RecordDraftWithdrawn" -> "draft_withdrawn";
|
||||
case "RecordDraftSubmitted" -> "draft_submitted";
|
||||
default -> throw new IllegalArgumentException("Unknown draft event type: " + eventType);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build positional params array for {@code MessageRenderer.render()}.
|
||||
* Order per notifications_ru.properties:
|
||||
* {0}=dictDisplayName, {1}=businessKey, {2}=makerId, {3}=operation,
|
||||
* {4}=baseUrl, {5}=draftId, {6}=decisionOutcome(0/1), {7}=reviewerId,
|
||||
* {8}=commentPresent(0/1), {9}=comment
|
||||
*/
|
||||
private Object[] buildParams(NotificationEvent event, ChannelKind channel) {
|
||||
NotificationEvent.DraftRef draft = event.draft();
|
||||
NotificationEvent.Decision decision = event.decision();
|
||||
|
||||
String dictDisplayName = draft != null ? draft.dictionary() : "";
|
||||
String businessKey = draft != null ? draft.businessKey() : "";
|
||||
String maker = draft != null ? draft.maker() : "";
|
||||
String operation = draft != null ? draft.operation() : "";
|
||||
long draftIdLong = extractDraftIdLong(draft);
|
||||
|
||||
int decisionOutcome = 0;
|
||||
String reviewerId = "";
|
||||
int commentPresent = 0;
|
||||
String comment = "";
|
||||
|
||||
if (decision != null) {
|
||||
decisionOutcome = "APPROVE".equals(decision.outcome()) ? 1 : 0;
|
||||
reviewerId = decision.decidedBy() != null ? decision.decidedBy() : "";
|
||||
comment = decision.comment() != null ? decision.comment() : "";
|
||||
commentPresent = comment.isBlank() ? 0 : 1;
|
||||
}
|
||||
|
||||
return new Object[]{
|
||||
dictDisplayName, // {0}
|
||||
businessKey, // {1}
|
||||
maker, // {2}
|
||||
operation, // {3}
|
||||
baseUrl, // {4}
|
||||
draftIdLong, // {5}
|
||||
decisionOutcome, // {6}
|
||||
reviewerId, // {7}
|
||||
commentPresent, // {8}
|
||||
comment // {9}
|
||||
};
|
||||
}
|
||||
|
||||
private long extractDraftIdLong(NotificationEvent.DraftRef draft) {
|
||||
if (draft == null || draft.id() == null) return 0L;
|
||||
// Draft ID is UUID; templates show it as a link fragment — use hashCode as numeric proxy
|
||||
return Math.abs(draft.id().getMostSignificantBits());
|
||||
}
|
||||
|
||||
private NotificationEvent buildNotificationEvent(OutboxEvent event, UUID eventUuid, JsonNode payload) {
|
||||
String eventType = event.getEventType();
|
||||
String makerId = stringField(payload, "makerId");
|
||||
String reviewerId = stringField(payload, "reviewerId");
|
||||
String reviewComment = stringField(payload, "reviewComment");
|
||||
String draftIdStr = stringField(payload, "draftId");
|
||||
String businessKey = stringField(payload, "businessKey");
|
||||
String dictionaryName = stringField(payload, "dictionaryName");
|
||||
String operation = stringField(payload, "operation");
|
||||
|
||||
UUID draftUuid = draftIdStr != null ? parseUuidOrNull(draftIdStr) : null;
|
||||
|
||||
NotificationEvent.DraftRef draftRef = new NotificationEvent.DraftRef(
|
||||
draftUuid, businessKey, dictionaryName, operation, makerId);
|
||||
|
||||
NotificationEvent.Decision decision = null;
|
||||
if ("RecordDraftApproved".equals(eventType) || "RecordDraftRejected".equals(eventType)) {
|
||||
String outcome = "RecordDraftApproved".equals(eventType) ? "APPROVE" : "REJECT";
|
||||
decision = new NotificationEvent.Decision(outcome, reviewerId, reviewComment);
|
||||
}
|
||||
|
||||
return new NotificationEvent(
|
||||
eventUuid,
|
||||
eventType,
|
||||
event.getCreatedAt(),
|
||||
draftRef,
|
||||
decision,
|
||||
null);
|
||||
}
|
||||
|
||||
private UUID extractEventUuid(JsonNode payload, OutboxEvent event) {
|
||||
String uuidStr = stringField(payload, "eventId");
|
||||
if (uuidStr != null) {
|
||||
UUID parsed = parseUuidOrNull(uuidStr);
|
||||
if (parsed != null) return parsed;
|
||||
}
|
||||
// Derive deterministic UUID from outbox event's numeric id if not present.
|
||||
// event.getId() may be null in tests (entity not persisted). Fall back to random.
|
||||
Long numericId = event.getId();
|
||||
return numericId != null ? new UUID(0L, numericId) : UUID.randomUUID();
|
||||
}
|
||||
|
||||
private static String stringField(JsonNode node, String field) {
|
||||
if (node == null || !node.has(field)) return null;
|
||||
JsonNode val = node.get(field);
|
||||
return val.isNull() ? null : val.asText();
|
||||
}
|
||||
|
||||
private static UUID parseUuidOrNull(String s) {
|
||||
if (s == null || s.isBlank()) return null;
|
||||
try {
|
||||
return UUID.fromString(s);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package cloud.nstart.terravault.ordinis.notifications.dispatcher;
|
||||
|
||||
import cloud.nstart.terravault.ordinis.notifications.channel.UserRef;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Resolves a Keycloak subject (user UUID) to a {@link UserRef} for notification dispatch.
|
||||
*
|
||||
* <p>Backed by {@code UserDisplayService} (ordinis-rest-api module) injected via interface
|
||||
* to keep ordinis-notifications free of a direct compile-time dependency on rest-api.
|
||||
* In production the {@link UserDisplayServiceBridge} implementation is wired by the app module;
|
||||
* in unit tests a mock can be provided directly.
|
||||
*/
|
||||
public interface RecipientResolver {
|
||||
|
||||
/**
|
||||
* Resolve user id → UserRef with email + locale. Returns empty when the user is unknown
|
||||
* (not in user_display_cache and Keycloak admin is not configured / unreachable).
|
||||
*
|
||||
* @param userId Keycloak subject (UUID string)
|
||||
* @return populated UserRef or empty
|
||||
*/
|
||||
Optional<UserRef> resolveById(String userId);
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package cloud.nstart.terravault.ordinis.notifications.dispatcher;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Narrow anti-corruption port over {@code UserDisplayService} (ordinis-rest-api).
|
||||
*
|
||||
* <p>Keeps ordinis-notifications independent of ordinis-rest-api at compile time.
|
||||
* The production implementation in ordinis-app delegates to {@code UserDisplayService}.
|
||||
* Tests can mock this port with a simple lambda.
|
||||
*/
|
||||
public interface UserDisplayPort {
|
||||
|
||||
Optional<DisplayInfo> findById(String userId);
|
||||
|
||||
record DisplayInfo(String sub, String email, String name) {}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package cloud.nstart.terravault.ordinis.notifications.dispatcher;
|
||||
|
||||
import cloud.nstart.terravault.ordinis.notifications.channel.UserRef;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Production {@link RecipientResolver} backed by a {@link UserDisplayPort} —
|
||||
* a narrow interface over {@code UserDisplayService} to avoid an explicit
|
||||
* compile-time dependency from ordinis-notifications on ordinis-rest-api.
|
||||
*
|
||||
* <p>The port is implemented in the app module (ordinis-app) which has
|
||||
* visibility over both modules. Tests can provide a mock port directly.
|
||||
*
|
||||
* <p>Locale: the user_display_cache table does not store locale; we default
|
||||
* to {@code ru} matching the existing {@code MessageRenderer.DEFAULT_LOCALE}
|
||||
* convention. When per-user locale is added, update this resolver.
|
||||
*/
|
||||
@Component
|
||||
@ConditionalOnProperty(name = "ordinis.notifications.enabled", havingValue = "true", matchIfMissing = false)
|
||||
public class UserDisplayRecipientResolver implements RecipientResolver {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(UserDisplayRecipientResolver.class);
|
||||
|
||||
private final UserDisplayPort userDisplayPort;
|
||||
|
||||
public UserDisplayRecipientResolver(UserDisplayPort userDisplayPort) {
|
||||
this.userDisplayPort = userDisplayPort;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<UserRef> resolveById(String userId) {
|
||||
if (userId == null || userId.isBlank()) return Optional.empty();
|
||||
|
||||
Optional<UserDisplayPort.DisplayInfo> info = userDisplayPort.findById(userId);
|
||||
if (info.isEmpty()) {
|
||||
log.debug("RecipientResolver: user {} not found in display cache", userId);
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
UserDisplayPort.DisplayInfo d = info.get();
|
||||
// Phase A: email only; expressHuid/telegramChatId resolved later (Phase B/C).
|
||||
return Optional.of(new UserRef(userId, d.email(), null, null, Locale.forLanguageTag("ru")));
|
||||
}
|
||||
}
|
||||
+337
@@ -0,0 +1,337 @@
|
||||
package cloud.nstart.terravault.ordinis.notifications.dispatcher;
|
||||
|
||||
import cloud.nstart.terravault.ordinis.domain.DataScope;
|
||||
import cloud.nstart.terravault.ordinis.domain.notification.NotificationLog;
|
||||
import cloud.nstart.terravault.ordinis.domain.notification.NotificationLogRepository;
|
||||
import cloud.nstart.terravault.ordinis.domain.outbox.OutboxEvent;
|
||||
import cloud.nstart.terravault.ordinis.domain.outbox.OutboxEventRepository;
|
||||
import cloud.nstart.terravault.ordinis.notifications.channel.ChannelKind;
|
||||
import cloud.nstart.terravault.ordinis.notifications.channel.NotificationChannel;
|
||||
import cloud.nstart.terravault.ordinis.notifications.channel.RenderedMessage;
|
||||
import cloud.nstart.terravault.ordinis.notifications.channel.UserRef;
|
||||
import cloud.nstart.terravault.ordinis.notifications.event.NotificationEvent;
|
||||
import cloud.nstart.terravault.ordinis.notifications.template.MessageRenderer;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* Integration-style test for {@link NotificationsDispatcher}.
|
||||
*
|
||||
* <p>Uses mocks for external dependencies (OutboxEventRepository, MessageRenderer,
|
||||
* RecipientResolver) and a stub IdempotencyGuard (because Mockito cannot subclass
|
||||
* Spring @Transactional components without mockito-subclass agent). Validates the
|
||||
* full dispatch pipeline:
|
||||
* <ol>
|
||||
* <li>Seeded outbox event of type {@code RecordDraftApproved}</li>
|
||||
* <li>Dispatcher sendTick() triggered manually</li>
|
||||
* <li>IdempotencyGuard claim called — NotificationLog row created</li>
|
||||
* <li>Mock EmailChannel dispatch() called with rendered message</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>Named *Test (not *IT) so Maven Surefire discovers it in the default include pattern.
|
||||
*/
|
||||
class NotificationsDispatcherTest {
|
||||
|
||||
// Mocks
|
||||
private OutboxEventRepository outboxRepository;
|
||||
private MessageRenderer messageRenderer;
|
||||
private RecipientResolver recipientResolver;
|
||||
|
||||
// Test doubles
|
||||
private RecordingEmailChannel emailChannel;
|
||||
private StubIdempotencyGuard idempotencyGuard;
|
||||
private StubNotificationLogRepository notificationLogRepository;
|
||||
|
||||
private NotificationsDispatcher dispatcher;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
outboxRepository = mock(OutboxEventRepository.class);
|
||||
messageRenderer = mock(MessageRenderer.class);
|
||||
recipientResolver = mock(RecipientResolver.class);
|
||||
emailChannel = new RecordingEmailChannel();
|
||||
notificationLogRepository = new StubNotificationLogRepository();
|
||||
idempotencyGuard = new StubIdempotencyGuard(notificationLogRepository);
|
||||
|
||||
dispatcher = new NotificationsDispatcher(
|
||||
outboxRepository,
|
||||
notificationLogRepository,
|
||||
idempotencyGuard,
|
||||
messageRenderer,
|
||||
List.of(emailChannel),
|
||||
recipientResolver,
|
||||
new SimpleMeterRegistry()
|
||||
);
|
||||
|
||||
ReflectionTestUtils.setField(dispatcher, "batchSize", 50);
|
||||
ReflectionTestUtils.setField(dispatcher, "pollIntervalMs", 30_000L);
|
||||
ReflectionTestUtils.setField(dispatcher, "reviewerPoolEmail", "");
|
||||
ReflectionTestUtils.setField(dispatcher, "baseUrl", "https://ordinis.example");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("RecordDraftApproved event → maker receives email notification, NotificationLog row created")
|
||||
void draftApproved_makerGetsEmail() {
|
||||
UUID draftId = UUID.randomUUID();
|
||||
String makerId = "maker-user-sub-123";
|
||||
String makerEmail = "maker@nstart.space";
|
||||
|
||||
OutboxEvent event = buildDraftApprovedEvent(draftId, makerId);
|
||||
when(outboxRepository.findUnpublished(any(Pageable.class))).thenReturn(List.of(event));
|
||||
|
||||
UserRef makerRef = new UserRef(makerId, makerEmail, null, null, Locale.forLanguageTag("ru"));
|
||||
when(recipientResolver.resolveById(makerId)).thenReturn(Optional.of(makerRef));
|
||||
|
||||
RenderedMessage rendered = new RenderedMessage("[НСИ] Draft одобрен — Test / SPUTNIK-1", "Ваш draft одобрен.");
|
||||
when(messageRenderer.render(anyString(), any(ChannelKind.class), any(Locale.class), any(Object[].class)))
|
||||
.thenReturn(rendered);
|
||||
|
||||
// Trigger dispatcher
|
||||
dispatcher.sendTick();
|
||||
|
||||
// Assert: EmailChannel.dispatch() was called once for the maker
|
||||
assertThat(emailChannel.dispatchCalls).hasSize(1);
|
||||
RecordingEmailChannel.Call call = emailChannel.dispatchCalls.get(0);
|
||||
assertThat(call.recipient.email()).isEqualTo(makerEmail);
|
||||
assertThat(call.msg.subject()).contains("Draft одобрен");
|
||||
|
||||
// Assert: NotificationLog row was claimed and marked SENT
|
||||
assertThat(notificationLogRepository.savedRows).hasSize(1);
|
||||
assertThat(idempotencyGuard.markedSentCount.get()).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Non-draft event types are ignored")
|
||||
void nonDraftEvents_areIgnored() {
|
||||
OutboxEvent event = new OutboxEvent(
|
||||
"DictionaryRecordCreated", "DictionaryRecord", "spacecraft:test",
|
||||
DataScope.PUBLIC,
|
||||
new ObjectMapper().createObjectNode(),
|
||||
"ordinis.cuod.events.public", "spacecraft:test");
|
||||
|
||||
when(outboxRepository.findUnpublished(any(Pageable.class))).thenReturn(List.of(event));
|
||||
|
||||
dispatcher.sendTick();
|
||||
|
||||
assertThat(emailChannel.dispatchCalls).isEmpty();
|
||||
verify(recipientResolver, never()).resolveById(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Idempotency conflict → no dispatch, markSent never called")
|
||||
void idempotencyConflict_skipsDispatch() {
|
||||
UUID draftId = UUID.randomUUID();
|
||||
String makerId = "maker-sub-456";
|
||||
OutboxEvent event = buildDraftApprovedEvent(draftId, makerId);
|
||||
when(outboxRepository.findUnpublished(any(Pageable.class))).thenReturn(List.of(event));
|
||||
|
||||
UserRef makerRef = new UserRef(makerId, "maker@nstart.space", null, null, Locale.forLanguageTag("ru"));
|
||||
when(recipientResolver.resolveById(makerId)).thenReturn(Optional.of(makerRef));
|
||||
|
||||
// Simulate idempotency conflict — guard returns empty
|
||||
idempotencyGuard.simulateConflict = true;
|
||||
|
||||
dispatcher.sendTick();
|
||||
|
||||
assertThat(emailChannel.dispatchCalls).isEmpty();
|
||||
assertThat(idempotencyGuard.markedSentCount.get()).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Event key mapping is correct for all draft event types")
|
||||
void eventKeyMapping_allTypes() {
|
||||
assertThat(NotificationsDispatcher.toEventKey("RecordDraftRejected")).isEqualTo("draft_decision");
|
||||
assertThat(NotificationsDispatcher.toEventKey("RecordDraftApproved")).isEqualTo("draft_decision");
|
||||
assertThat(NotificationsDispatcher.toEventKey("RecordDraftSubmitted")).isEqualTo("draft_submitted");
|
||||
assertThat(NotificationsDispatcher.toEventKey("RecordDraftWithdrawn")).isEqualTo("draft_withdrawn");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Unknown user in display cache → no recipients → no dispatch")
|
||||
void unknownMaker_noDispatch() {
|
||||
UUID draftId = UUID.randomUUID();
|
||||
OutboxEvent event = buildDraftApprovedEvent(draftId, "unknown-sub");
|
||||
when(outboxRepository.findUnpublished(any(Pageable.class))).thenReturn(List.of(event));
|
||||
|
||||
when(recipientResolver.resolveById("unknown-sub")).thenReturn(Optional.empty());
|
||||
|
||||
dispatcher.sendTick();
|
||||
|
||||
assertThat(emailChannel.dispatchCalls).isEmpty();
|
||||
assertThat(notificationLogRepository.savedRows).isEmpty();
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
private OutboxEvent buildDraftApprovedEvent(UUID draftId, String makerId) {
|
||||
ObjectNode payload = new ObjectMapper().createObjectNode();
|
||||
payload.put("draftId", draftId.toString());
|
||||
payload.put("dictionaryName", "spacecraft");
|
||||
payload.put("businessKey", "SPUTNIK-1");
|
||||
payload.put("operation", "CREATE");
|
||||
payload.put("status", "APPROVED");
|
||||
payload.put("makerId", makerId);
|
||||
payload.put("reviewerId", "reviewer-sub-789");
|
||||
payload.put("reviewComment", "Looks good");
|
||||
payload.put("reviewedAt", "2026-05-14T10:00:00Z");
|
||||
|
||||
return new OutboxEvent(
|
||||
"RecordDraftApproved",
|
||||
"RecordDraft",
|
||||
draftId.toString(),
|
||||
DataScope.PUBLIC,
|
||||
payload,
|
||||
"ordinis.cuod.events.public",
|
||||
"record-draft:" + draftId);
|
||||
}
|
||||
|
||||
// --- Test doubles ---
|
||||
|
||||
/**
|
||||
* Stub IdempotencyGuard — avoids mocking a Spring @Transactional component.
|
||||
* Delegates to a shared StubNotificationLogRepository.
|
||||
*/
|
||||
static class StubIdempotencyGuard extends IdempotencyGuard {
|
||||
|
||||
boolean simulateConflict = false;
|
||||
final AtomicInteger markedSentCount = new AtomicInteger(0);
|
||||
private final StubNotificationLogRepository repo;
|
||||
|
||||
StubIdempotencyGuard(StubNotificationLogRepository repo) {
|
||||
super(repo);
|
||||
this.repo = repo;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<NotificationLog> tryClaim(
|
||||
NotificationEvent event, ChannelKind channel, String recipient) {
|
||||
if (simulateConflict) return Optional.empty();
|
||||
|
||||
NotificationLog row = new NotificationLog(
|
||||
event.eventId() != null ? event.eventId() : UUID.randomUUID(),
|
||||
event.eventType(),
|
||||
event.draft() != null ? event.draft().id() : null,
|
||||
channel.wireName(),
|
||||
recipient);
|
||||
repo.savedRows.add(row);
|
||||
return Optional.of(row);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void markSent(Long rowId) {
|
||||
markedSentCount.incrementAndGet();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void markFailed(Long rowId, String errorMsg) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub NotificationLogRepository — in-memory list.
|
||||
*/
|
||||
static class StubNotificationLogRepository implements NotificationLogRepository {
|
||||
|
||||
final List<NotificationLog> savedRows = new ArrayList<>();
|
||||
private long nextId = 1L;
|
||||
|
||||
@Override
|
||||
public Optional<Long> tryClaim(UUID eventId, String eventType, UUID draftId,
|
||||
String channel, String recipient) {
|
||||
// Used by real IdempotencyGuard; our stub overrides tryClaim directly
|
||||
return Optional.of(nextId++);
|
||||
}
|
||||
|
||||
@Override
|
||||
public NotificationLog save(NotificationLog entity) {
|
||||
savedRows.add(entity);
|
||||
return entity;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<NotificationLog> findById(Long aLong) {
|
||||
return savedRows.stream()
|
||||
.filter(r -> aLong.equals(r.getId()))
|
||||
.findFirst();
|
||||
}
|
||||
|
||||
// --- unused stubs ---
|
||||
|
||||
@Override public <S extends NotificationLog> List<S> saveAll(Iterable<S> entities) { return List.of(); }
|
||||
@Override public void flush() {}
|
||||
@Override public <S extends NotificationLog> S saveAndFlush(S entity) { return entity; }
|
||||
@Override public <S extends NotificationLog> List<S> saveAllAndFlush(Iterable<S> entities) { return List.of(); }
|
||||
@Override public void deleteAllInBatch(Iterable<NotificationLog> entities) {}
|
||||
@Override public void deleteAllByIdInBatch(Iterable<Long> longs) {}
|
||||
@Override public void deleteAllInBatch() {}
|
||||
@Override public NotificationLog getOne(Long aLong) { return null; }
|
||||
@Override public NotificationLog getById(Long aLong) { return null; }
|
||||
@Override public NotificationLog getReferenceById(Long aLong) { return null; }
|
||||
@Override public <S extends NotificationLog> Optional<S> findOne(org.springframework.data.domain.Example<S> example) { return Optional.empty(); }
|
||||
@Override public <S extends NotificationLog> List<S> findAll(org.springframework.data.domain.Example<S> example) { return List.of(); }
|
||||
@Override public <S extends NotificationLog> List<S> findAll(org.springframework.data.domain.Example<S> example, org.springframework.data.domain.Sort sort) { return List.of(); }
|
||||
@Override public <S extends NotificationLog> org.springframework.data.domain.Page<S> findAll(org.springframework.data.domain.Example<S> example, Pageable pageable) { return org.springframework.data.domain.Page.empty(); }
|
||||
@Override public <S extends NotificationLog> long count(org.springframework.data.domain.Example<S> example) { return 0; }
|
||||
@Override public <S extends NotificationLog> boolean exists(org.springframework.data.domain.Example<S> example) { return false; }
|
||||
@Override public <S extends NotificationLog, R> R findBy(org.springframework.data.domain.Example<S> example, java.util.function.Function<org.springframework.data.repository.query.FluentQuery.FetchableFluentQuery<S>, R> queryFunction) { return null; }
|
||||
@Override public boolean existsById(Long aLong) { return false; }
|
||||
@Override public List<NotificationLog> findAll() { return savedRows; }
|
||||
@Override public List<NotificationLog> findAllById(Iterable<Long> longs) { return List.of(); }
|
||||
@Override public long count() { return savedRows.size(); }
|
||||
@Override public void deleteById(Long aLong) {}
|
||||
@Override public void delete(NotificationLog entity) {}
|
||||
@Override public void deleteAllById(Iterable<? extends Long> longs) {}
|
||||
@Override public void deleteAll(Iterable<? extends NotificationLog> entities) {}
|
||||
@Override public void deleteAll() { savedRows.clear(); }
|
||||
@Override public List<NotificationLog> findAll(org.springframework.data.domain.Sort sort) { return savedRows; }
|
||||
@Override public org.springframework.data.domain.Page<NotificationLog> findAll(Pageable pageable) { return org.springframework.data.domain.Page.empty(); }
|
||||
@Override public int deletePendingOlderThan(OffsetDateTime threshold) { return 0; }
|
||||
@Override public int deleteRetainedOlderThan(OffsetDateTime threshold) { return 0; }
|
||||
@Override public Optional<OffsetDateTime> findLastSentAt(String recipient, String channel) { return Optional.empty(); }
|
||||
@Override public org.springframework.data.domain.Page<NotificationLog> findByRecipientOrderBySentAtDesc(String recipient, Pageable pageable) { return org.springframework.data.domain.Page.empty(); }
|
||||
@Override public org.springframework.data.domain.Page<NotificationLog> findByEventIdOrderBySentAtDesc(UUID eventId, Pageable pageable) { return org.springframework.data.domain.Page.empty(); }
|
||||
}
|
||||
|
||||
/**
|
||||
* Recording EmailChannel — captures all dispatch calls for assertion.
|
||||
*/
|
||||
static class RecordingEmailChannel implements NotificationChannel {
|
||||
|
||||
record Call(NotificationEvent event, UserRef recipient, RenderedMessage msg) {}
|
||||
|
||||
final List<Call> dispatchCalls = new ArrayList<>();
|
||||
|
||||
@Override
|
||||
public ChannelKind kind() { return ChannelKind.EMAIL; }
|
||||
|
||||
@Override
|
||||
public boolean isEnabled() { return true; }
|
||||
|
||||
@Override
|
||||
public DispatchResult dispatch(NotificationEvent event, UserRef recipient, RenderedMessage msg) {
|
||||
dispatchCalls.add(new Call(event, recipient, msg));
|
||||
return DispatchResult.success();
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
mock-maker-subclass
|
||||
Reference in New Issue
Block a user