feat(notifications): TODO 7 — draft decision toast + email + bell badge

This commit is contained in:
Александр Зимин
2026-05-14 14:40:35 +00:00
parent 50d263745a
commit 9050e2427e
21 changed files with 1992 additions and 82 deletions
@@ -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();
}
}
}
@@ -0,0 +1 @@
mock-maker-subclass