From e6652549b6b45a58ba6117ab82a95e668fd9c5ef Mon Sep 17 00:00:00 2001 From: saba_zedginidze Date: Mon, 20 Jul 2026 12:51:27 +0400 Subject: [PATCH 01/12] [MODNOTES-295] Update pom and properties to include new deps --- pom.xml | 21 +++++++++++++++++++++ src/main/resources/application.yaml | 23 ++++++++++++++++++++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 174c78f7..05cfc563 100644 --- a/pom.xml +++ b/pom.xml @@ -78,6 +78,16 @@ org.springframework.boot spring-boot-starter-validation + + org.springframework.boot + spring-boot-starter-kafka + + + org.springframework.boot + spring-boot-starter-logging + + + org.springframework.boot spring-boot-starter-actuator @@ -150,6 +160,17 @@ ${folio-spring-support.version} test + + org.springframework.boot + spring-boot-starter-kafka-test + test + + + org.springframework.boot + spring-boot-starter-logging + + + org.springframework.boot spring-boot-starter-webmvc-test diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 5f959253..6050dd93 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -1,5 +1,6 @@ # Module properties folio: + environment: ${ENV:folio} exchange: enabled: true tenant: @@ -47,11 +48,31 @@ folio: - target response: limit: ${MAX_RECORDS_COUNT:1000} + kafka: + topics: + note: notes.note # Spring properties spring: application: name: mod-notes + kafka: + bootstrap-servers: ${KAFKA_HOST:localhost}:${KAFKA_PORT:9092} + producer: + client-id: mod-notes + key-serializer: org.apache.kafka.common.serialization.UUIDSerializer + value-serializer: org.springframework.kafka.support.serializer.JacksonJsonSerializer + acks: all + retries: 3 + properties: + max.block.ms: ${KAFKA_PRODUCER_MAX_BLOCK_MS:10000} + security: + protocol: ${KAFKA_SECURITY_PROTOCOL:PLAINTEXT} + ssl: + key-store-password: ${KAFKA_SSL_KEYSTORE_PASSWORD:} + key-store-location: ${KAFKA_SSL_KEYSTORE_LOCATION:} + trust-store-password: ${KAFKA_SSL_TRUSTSTORE_PASSWORD:} + trust-store-location: ${KAFKA_SSL_TRUSTSTORE_LOCATION:} sql: init: continue-on-error: true @@ -94,4 +115,4 @@ server: logging: level: - org.folio.spring.filter.IncomingRequestLoggingFilter: DEBUG \ No newline at end of file + org.folio.spring.filter.IncomingRequestLoggingFilter: DEBUG From 2249419af086c0b97946e49d5c2f733a30924ec9 Mon Sep 17 00:00:00 2001 From: saba_zedginidze Date: Mon, 20 Jul 2026 12:51:51 +0400 Subject: [PATCH 02/12] [MODNOTES-295] Create kafka configuration and producer --- .../notes/config/KafkaConfiguration.java | 33 +++++++++++ .../integration/kafka/NoteEventProducer.java | 58 +++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 src/main/java/org/folio/notes/config/KafkaConfiguration.java create mode 100644 src/main/java/org/folio/notes/integration/kafka/NoteEventProducer.java diff --git a/src/main/java/org/folio/notes/config/KafkaConfiguration.java b/src/main/java/org/folio/notes/config/KafkaConfiguration.java new file mode 100644 index 00000000..841c9b56 --- /dev/null +++ b/src/main/java/org/folio/notes/config/KafkaConfiguration.java @@ -0,0 +1,33 @@ +package org.folio.notes.config; + +import java.util.UUID; +import org.folio.notes.domain.dto.Note; +import org.folio.notes.domain.event.DomainEvent; +import org.springframework.boot.kafka.autoconfigure.KafkaProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.core.DefaultKafkaProducerFactory; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.core.ProducerFactory; + +/** + * Kafka producer configuration for Note domain events. + * + *

Defines the {@link ProducerFactory} and {@link KafkaTemplate} used to publish {@link DomainEvent} envelopes + * keyed by the Note id. Serializers are driven by the {@code spring.kafka.producer} configuration + * ({@code UUIDSerializer} for the key, {@code JacksonJsonSerializer} for the value).

+ */ +@Configuration +public class KafkaConfiguration { + + @Bean + public ProducerFactory> noteEventProducerFactory(KafkaProperties kafkaProperties) { + return new DefaultKafkaProducerFactory<>(kafkaProperties.buildProducerProperties()); + } + + @Bean + public KafkaTemplate> noteEventKafkaTemplate( + ProducerFactory> noteEventProducerFactory) { + return new KafkaTemplate<>(noteEventProducerFactory); + } +} diff --git a/src/main/java/org/folio/notes/integration/kafka/NoteEventProducer.java b/src/main/java/org/folio/notes/integration/kafka/NoteEventProducer.java new file mode 100644 index 00000000..29ac3b1e --- /dev/null +++ b/src/main/java/org/folio/notes/integration/kafka/NoteEventProducer.java @@ -0,0 +1,58 @@ +package org.folio.notes.integration.kafka; + +import java.util.UUID; +import lombok.extern.slf4j.Slf4j; +import org.folio.notes.domain.dto.Note; +import org.folio.notes.domain.event.DomainEvent; +import org.folio.spring.FolioExecutionContext; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Component; + +/** + * Publishes Note {@link DomainEvent}s to the tenant-scoped Kafka topic. + * + *

The topic name follows the FOLIO convention {@code {env}.{tenant}.notes.note} (for example + * {@code folio.diku.notes.note}). The tenant is resolved at publish time via {@link FolioExecutionContext} + * and {@code env} defaults to {@code folio}.

+ */ +@Slf4j +@Component +public class NoteEventProducer { + + private final KafkaTemplate> kafkaTemplate; + private final FolioExecutionContext context; + private final String env; + private final String noteTopic; + + public NoteEventProducer(KafkaTemplate> kafkaTemplate, + FolioExecutionContext context, + @Value("${folio.environment:folio}") String env, + @Value("${folio.kafka.topics.note:notes.note}") String noteTopic) { + this.kafkaTemplate = kafkaTemplate; + this.context = context; + this.env = env; + this.noteTopic = noteTopic; + } + + /** + * Publishes a Note domain event to the tenant-scoped topic. + * + * @param id the Note id used as the Kafka record key + * @param event the domain event envelope to publish + */ + public void publish(UUID id, DomainEvent event) { + var topic = topicName(); + try { + log.debug("publish:: sending Note event [topic: {}, id: {}, type: {}]", topic, id, event.getType()); + kafkaTemplate.send(topic, id, event); + log.info("publish:: Note event sent [topic: {}, id: {}, type: {}]", topic, id, event.getType()); + } catch (Exception e) { + log.error("publish:: failed to send Note event [topic: {}, id: {}, type: {}]", topic, id, event.getType(), e); + } + } + + private String topicName() { + return String.format("%s.%s.%s", env, context.getTenantId(), noteTopic); + } +} From b6aa62696ed7e10a9ec5b1372033b358e4780197 Mon Sep 17 00:00:00 2001 From: saba_zedginidze Date: Mon, 20 Jul 2026 12:52:24 +0400 Subject: [PATCH 03/12] [MODNOTES-295] Create publisher service for domain events --- .../folio/notes/domain/event/DomainEvent.java | 59 ++++++++++++++++++ .../notes/domain/event/DomainEventType.java | 5 ++ .../service/DomainEventPublisherService.java | 60 +++++++++++++++++++ 3 files changed, 124 insertions(+) create mode 100644 src/main/java/org/folio/notes/domain/event/DomainEvent.java create mode 100644 src/main/java/org/folio/notes/domain/event/DomainEventType.java create mode 100644 src/main/java/org/folio/notes/service/DomainEventPublisherService.java diff --git a/src/main/java/org/folio/notes/domain/event/DomainEvent.java b/src/main/java/org/folio/notes/domain/event/DomainEvent.java new file mode 100644 index 00000000..1e8a9d2e --- /dev/null +++ b/src/main/java/org/folio/notes/domain/event/DomainEvent.java @@ -0,0 +1,59 @@ +package org.folio.notes.domain.event; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.UUID; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Envelope for a mod-notes domain event. + * + * @param the payload type carried in the {@code old} and {@code new} fields (a full entity snapshot). + */ +@Data +@NoArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class DomainEvent { + + private UUID id; + @JsonProperty("old") + private T oldEntity; + @JsonProperty("new") + private T newEntity; + private DomainEventType type; + private String tenant; + private String ts; + + @JsonCreator + public DomainEvent(@JsonProperty("id") UUID id, + @JsonProperty("old") T oldEntity, + @JsonProperty("new") T newEntity, + @JsonProperty("type") DomainEventType type, + @JsonProperty("tenant") String tenant, + @JsonProperty("ts") String ts) { + this.id = id; + this.oldEntity = oldEntity; + this.newEntity = newEntity; + this.type = type; + this.tenant = tenant; + this.ts = ts; + } + + public static DomainEvent createEvent(UUID id, T newEntity, String tenant) { + return new DomainEvent<>(id, null, newEntity, DomainEventType.CREATE, tenant, currentTs()); + } + + public static DomainEvent updateEvent(UUID id, T oldEntity, T newEntity, String tenant) { + return new DomainEvent<>(id, oldEntity, newEntity, DomainEventType.UPDATE, tenant, currentTs()); + } + + public static DomainEvent deleteEvent(UUID id, T oldEntity, String tenant) { + return new DomainEvent<>(id, oldEntity, null, DomainEventType.DELETE, tenant, currentTs()); + } + + private static String currentTs() { + return String.valueOf(System.currentTimeMillis()); + } +} diff --git a/src/main/java/org/folio/notes/domain/event/DomainEventType.java b/src/main/java/org/folio/notes/domain/event/DomainEventType.java new file mode 100644 index 00000000..cb56086d --- /dev/null +++ b/src/main/java/org/folio/notes/domain/event/DomainEventType.java @@ -0,0 +1,5 @@ +package org.folio.notes.domain.event; + +public enum DomainEventType { + CREATE, UPDATE, DELETE +} diff --git a/src/main/java/org/folio/notes/service/DomainEventPublisherService.java b/src/main/java/org/folio/notes/service/DomainEventPublisherService.java new file mode 100644 index 00000000..1ecc2ee7 --- /dev/null +++ b/src/main/java/org/folio/notes/service/DomainEventPublisherService.java @@ -0,0 +1,60 @@ +package org.folio.notes.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.folio.notes.domain.dto.Note; +import org.folio.notes.domain.event.DomainEvent; +import org.folio.notes.integration.kafka.NoteEventProducer; +import org.folio.spring.FolioExecutionContext; +import org.springframework.stereotype.Service; + +/** + * Builds {@link DomainEvent} envelopes for Note changes and hands them to the {@link NoteEventProducer}. + * + *

Callers use the event-specific methods ({@link #publishNoteCreatedEvent(Note)}, + * {@link #publishNoteUpdatedEvent(Note, Note)}, {@link #publishNoteDeletedEvent(Note)}) and only need to provide the + * relevant Note snapshot(s); this service takes care of resolving the current tenant via {@link FolioExecutionContext}, + * choosing the Kafka record key and populating the {@code old}/{@code new} envelope fields for each event type.

+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class DomainEventPublisherService { + + private final NoteEventProducer noteEventProducer; + private final FolioExecutionContext context; + + /** + * Publishes a {@code CREATE} Note event carrying the new snapshot. + * + * @param newNote the newly-created Note snapshot + */ + public void publishNoteCreatedEvent(Note newNote) { + publish(DomainEvent.createEvent(newNote.getId(), newNote, context.getTenantId())); + } + + /** + * Publishes an {@code UPDATE} Note event carrying both the pre- and post-change snapshots. + * + * @param oldNote the pre-change Note snapshot + * @param newNote the post-change Note snapshot + */ + public void publishNoteUpdatedEvent(Note oldNote, Note newNote) { + publish(DomainEvent.updateEvent(newNote.getId(), oldNote, newNote, context.getTenantId())); + } + + /** + * Publishes a {@code DELETE} Note event carrying the pre-delete snapshot. + * + * @param oldNote the pre-delete Note snapshot + */ + public void publishNoteDeletedEvent(Note oldNote) { + publish(DomainEvent.deleteEvent(oldNote.getId(), oldNote, context.getTenantId())); + } + + private void publish(DomainEvent event) { + log.debug("publish:: publishing Note event [id: {}, type: {}, tenant: {}]", + event.getId(), event.getType(), event.getTenant()); + noteEventProducer.publish(event.getId(), event); + } +} From 829a2a0fbce951f757e1045aa41e26b17e7d27ba Mon Sep 17 00:00:00 2001 From: saba_zedginidze Date: Mon, 20 Jul 2026 12:52:40 +0400 Subject: [PATCH 04/12] [MODNOTES-295] Update servce to send out events --- .../java/org/folio/notes/service/impl/NotesServiceImpl.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/folio/notes/service/impl/NotesServiceImpl.java b/src/main/java/org/folio/notes/service/impl/NotesServiceImpl.java index 8de228eb..8b4a0788 100644 --- a/src/main/java/org/folio/notes/service/impl/NotesServiceImpl.java +++ b/src/main/java/org/folio/notes/service/impl/NotesServiceImpl.java @@ -33,6 +33,7 @@ import org.folio.notes.domain.repository.NoteRepository; import org.folio.notes.domain.repository.NoteTypesRepository; import org.folio.notes.exception.NoteNotFoundException; +import org.folio.notes.service.DomainEventPublisherService; import org.folio.notes.service.NotesService; import org.folio.notes.util.HtmlSanitizer; import org.folio.spring.data.OffsetRequest; @@ -76,6 +77,7 @@ public class NotesServiceImpl implements NotesService { private final NotesMapper notesMapper; private final NoteCollectionMapper noteCollectionMapper; private final HtmlSanitizer sanitizer; + private final DomainEventPublisherService domainEventPublisherService; @Value("${folio.notes.response.limit}") private Integer responseLimit; @@ -131,7 +133,9 @@ public Note createNote(Note note) { NoteEntity entity = saveNote(note, dto -> initNewEntity(notesMapper.toEntity(dto))); log.info("createNote:: created note by title: {}, domain: {}, type: {}", note.getTitle(), note.getDomain(), note.getType()); - return notesMapper.toDto(entity); + Note newSnapshot = notesMapper.toDto(entity); + domainEventPublisherService.publishNoteCreatedEvent(newSnapshot); + return newSnapshot; } @Transactional From 66c407933179075c764336f66131c0a114b1e91a Mon Sep 17 00:00:00 2001 From: saba_zedginidze Date: Mon, 20 Jul 2026 12:53:36 +0400 Subject: [PATCH 05/12] [MODNOTES-295] Create new unit tests --- .../notes/domain/event/DomainEventTest.java | 67 ++++++++++++++ .../DomainEventPublisherServiceTest.java | 87 +++++++++++++++++++ .../org/folio/notes/support/TestApiBase.java | 2 + 3 files changed, 156 insertions(+) create mode 100644 src/test/java/org/folio/notes/domain/event/DomainEventTest.java create mode 100644 src/test/java/org/folio/notes/service/DomainEventPublisherServiceTest.java diff --git a/src/test/java/org/folio/notes/domain/event/DomainEventTest.java b/src/test/java/org/folio/notes/domain/event/DomainEventTest.java new file mode 100644 index 00000000..2a38acbf --- /dev/null +++ b/src/test/java/org/folio/notes/domain/event/DomainEventTest.java @@ -0,0 +1,67 @@ +package org.folio.notes.domain.event; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.UUID; +import org.folio.notes.domain.dto.Note; +import org.folio.spring.testing.type.UnitTest; +import org.junit.jupiter.api.Test; +import tools.jackson.databind.json.JsonMapper; + +@UnitTest +class DomainEventTest { + + private static final JsonMapper MAPPER = JsonMapper.builder().build(); + + @Test + void createEvent_setsCreateTypeAndOmitsOld() { + var id = UUID.randomUUID(); + var note = new Note().title("t").domain("orders").typeId(UUID.randomUUID()); + + var event = DomainEvent.createEvent(id, note, "diku"); + + assertEquals(id, event.getId()); + assertEquals(DomainEventType.CREATE, event.getType()); + assertEquals("diku", event.getTenant()); + assertNull(event.getOldEntity()); + assertEquals(note, event.getNewEntity()); + assertNotNull(event.getTs()); + + var json = MAPPER.writeValueAsString(event); + assertTrue(json.contains("\"type\":\"CREATE\"")); + assertTrue(json.contains("\"new\"")); + assertFalse(json.contains("\"old\""), "NON_NULL must omit the null 'old' field"); + } + + @Test + void updateEvent_carriesOldAndNew() { + var id = UUID.randomUUID(); + var oldNote = new Note().title("old"); + var newNote = new Note().title("new"); + + var event = DomainEvent.updateEvent(id, oldNote, newNote, "diku"); + + assertEquals(DomainEventType.UPDATE, event.getType()); + assertEquals(oldNote, event.getOldEntity()); + assertEquals(newNote, event.getNewEntity()); + } + + @Test + void deleteEvent_carriesOldAndOmitsNew() { + var id = UUID.randomUUID(); + var oldNote = new Note().title("old"); + + var event = DomainEvent.deleteEvent(id, oldNote, "diku"); + + assertEquals(DomainEventType.DELETE, event.getType()); + assertEquals(oldNote, event.getOldEntity()); + assertNull(event.getNewEntity()); + } +} + + + diff --git a/src/test/java/org/folio/notes/service/DomainEventPublisherServiceTest.java b/src/test/java/org/folio/notes/service/DomainEventPublisherServiceTest.java new file mode 100644 index 00000000..d713f9a2 --- /dev/null +++ b/src/test/java/org/folio/notes/service/DomainEventPublisherServiceTest.java @@ -0,0 +1,87 @@ +package org.folio.notes.service; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.UUID; +import org.folio.notes.domain.dto.Note; +import org.folio.notes.domain.event.DomainEvent; +import org.folio.notes.domain.event.DomainEventType; +import org.folio.notes.integration.kafka.NoteEventProducer; +import org.folio.spring.FolioExecutionContext; +import org.folio.spring.testing.type.UnitTest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@UnitTest +@ExtendWith(MockitoExtension.class) +class DomainEventPublisherServiceTest { + + @Mock + private NoteEventProducer noteEventProducer; + @Mock + private FolioExecutionContext context; + @InjectMocks + private DomainEventPublisherService service; + + @Test + void publishNoteCreatedEvent_buildsCreateEnvelopeWithoutOld() { + var id = UUID.randomUUID(); + var note = new Note().id(id).title("t"); + when(context.getTenantId()).thenReturn("diku"); + + service.publishNoteCreatedEvent(note); + + var captor = eventCaptor(); + verify(noteEventProducer).publish(eq(id), captor.capture()); + var event = captor.getValue(); + org.junit.jupiter.api.Assertions.assertEquals(DomainEventType.CREATE, event.getType()); + org.junit.jupiter.api.Assertions.assertEquals("diku", event.getTenant()); + org.junit.jupiter.api.Assertions.assertEquals(note, event.getNewEntity()); + org.junit.jupiter.api.Assertions.assertNull(event.getOldEntity()); + } + + @Test + void publishNoteUpdatedEvent_carriesOldAndNew() { + var id = UUID.randomUUID(); + var oldNote = new Note().id(id).title("old"); + var newNote = new Note().id(id).title("new"); + when(context.getTenantId()).thenReturn("diku"); + + service.publishNoteUpdatedEvent(oldNote, newNote); + + var captor = eventCaptor(); + verify(noteEventProducer).publish(eq(id), captor.capture()); + var event = captor.getValue(); + org.junit.jupiter.api.Assertions.assertEquals(DomainEventType.UPDATE, event.getType()); + org.junit.jupiter.api.Assertions.assertEquals(oldNote, event.getOldEntity()); + org.junit.jupiter.api.Assertions.assertEquals(newNote, event.getNewEntity()); + } + + @Test + void publishNoteDeletedEvent_carriesOldAndOmitsNew() { + var id = UUID.randomUUID(); + var oldNote = new Note().id(id).title("old"); + when(context.getTenantId()).thenReturn("diku"); + + service.publishNoteDeletedEvent(oldNote); + + var captor = eventCaptor(); + verify(noteEventProducer).publish(eq(id), captor.capture()); + var event = captor.getValue(); + org.junit.jupiter.api.Assertions.assertEquals(DomainEventType.DELETE, event.getType()); + org.junit.jupiter.api.Assertions.assertEquals(oldNote, event.getOldEntity()); + org.junit.jupiter.api.Assertions.assertNull(event.getNewEntity()); + } + + @SuppressWarnings("unchecked") + private ArgumentCaptor> eventCaptor() { + return ArgumentCaptor.forClass(DomainEvent.class); + } +} + diff --git a/src/test/java/org/folio/notes/support/TestApiBase.java b/src/test/java/org/folio/notes/support/TestApiBase.java index aee8a5fe..022ce2d6 100644 --- a/src/test/java/org/folio/notes/support/TestApiBase.java +++ b/src/test/java/org/folio/notes/support/TestApiBase.java @@ -16,6 +16,7 @@ import org.folio.notes.domain.dto.User; import org.folio.spring.FolioModuleMetadata; import org.folio.spring.integration.XOkapiHeaders; +import org.folio.spring.testing.extension.EnableKafka; import org.folio.spring.testing.extension.EnableOkapi; import org.folio.spring.testing.extension.EnablePostgres; import org.folio.spring.testing.extension.impl.OkapiConfiguration; @@ -41,6 +42,7 @@ @EnableOkapi @EnablePostgres +@EnableKafka @AutoConfigureMockMvc @SpringBootTest @ContextConfiguration From c7293b8b775a108a3bac02298ea6615267b11b64 Mon Sep 17 00:00:00 2001 From: saba_zedginidze Date: Mon, 20 Jul 2026 12:54:28 +0400 Subject: [PATCH 06/12] [MODNOTES-295] Implement integration tests for domain events --- .../notes/controller/NoteDomainEventIT.java | 129 ++++++++++++++++ .../notes/support/TestKafkaConsumer.java | 143 ++++++++++++++++++ 2 files changed, 272 insertions(+) create mode 100644 src/test/java/org/folio/notes/controller/NoteDomainEventIT.java create mode 100644 src/test/java/org/folio/notes/support/TestKafkaConsumer.java diff --git a/src/test/java/org/folio/notes/controller/NoteDomainEventIT.java b/src/test/java/org/folio/notes/controller/NoteDomainEventIT.java new file mode 100644 index 00000000..0b2c1661 --- /dev/null +++ b/src/test/java/org/folio/notes/controller/NoteDomainEventIT.java @@ -0,0 +1,129 @@ +package org.folio.notes.controller; + +import static org.folio.notes.support.DatabaseHelper.LINK; +import static org.folio.notes.support.DatabaseHelper.NOTE; +import static org.folio.notes.support.DatabaseHelper.TYPE; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.time.Duration; +import java.util.List; +import java.util.UUID; +import org.folio.notes.domain.dto.Link; +import org.folio.notes.domain.dto.Note; +import org.folio.notes.domain.dto.User; +import org.folio.notes.domain.entity.NoteTypeEntity; +import org.folio.notes.support.TestApiBase; +import org.folio.notes.support.TestKafkaConsumer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.kafka.autoconfigure.KafkaProperties; +import tools.jackson.databind.JsonNode; + +@DisplayName("Note create domain events") +class NoteDomainEventIT extends TestApiBase { + + private static final String NOTE_URL = "/notes"; + private static final String NOTE_TOPIC = "folio.test.notes.note"; + private static final String ORDER_LINE_TYPE = "order-line"; + + private static final UUID NOTE_TYPE_ID = UUID.fromString("2af21797-d25b-46dc-8427-1759d1db2057"); + private static final String NOTE_TYPE_NAME = "General note"; + + @Autowired + private KafkaProperties kafkaProperties; + + private TestKafkaConsumer consumer; + + @BeforeEach + void setUp() { + stubUser(new User(USER_ID, "test_user", null)); + databaseHelper.clearTable(TENANT, NOTE); + databaseHelper.clearTable(TENANT, LINK); + databaseHelper.clearTable(TENANT, TYPE); + + var noteType = new NoteTypeEntity(); + noteType.setId(NOTE_TYPE_ID); + noteType.setName(NOTE_TYPE_NAME); + noteType.setCreatedBy(USER_ID); + databaseHelper.saveNoteType(noteType, TENANT); + + consumer = TestKafkaConsumer.subscribe(NOTE_TOPIC, kafkaProperties); + } + + @AfterEach + void tearDown() { + if (consumer != null) { + consumer.close(); + } + } + + @Test + @DisplayName("create publishes a CREATE event with the full snapshot and no 'old' field") + void createNote_publishesCreateEvent() throws Exception { + var link = new Link().id(UUID.randomUUID().toString()).type(ORDER_LINE_TYPE); + var note = new Note() + .title("Kafka title") + .content("Kafka details") + .domain("orders") + .typeId(NOTE_TYPE_ID) + .links(List.of(link)); + + var response = mockMvc.perform(post(NOTE_URL).headers(okapiHeaders()).content(asJsonString(note))) + .andExpect(status().isCreated()) + .andReturn().getResponse().getContentAsString(); + + var event = consumer.poll(); + var envelope = OBJECT_MAPPER.readTree(event.value()); + + assertEnvelope(envelope, event.value()); + assertPayload(envelope.get("new"), OBJECT_MAPPER.readTree(response).get("id").asString(), link); + } + + @Test + @DisplayName("invalid payload (missing title) publishes no event") + void createNote_invalidPayload_publishesNoEvent() throws Exception { + var note = new Note() + .domain("orders") + .typeId(NOTE_TYPE_ID) + .links(List.of(new Link().id(UUID.randomUUID().toString()).type(ORDER_LINE_TYPE))); + + mockMvc.perform(post(NOTE_URL).headers(okapiHeaders()).content(asJsonString(note))) + .andExpect(status().is4xxClientError()); + + var event = consumer.pollNullable(Duration.ofSeconds(10)); + assertNull(event, "No event should be published for an invalid create"); + } + + private void assertEnvelope(JsonNode envelope, String rawJson) { + assertNotNull(envelope.get("id"), "eventId (id) must be present"); + assertEquals("CREATE", envelope.get("type").asString()); + assertEquals(TENANT, envelope.get("tenant").asString()); + assertNull(envelope.get("old"), "CREATE event must not carry an 'old' field"); + assertFalse(rawJson.contains("\"old\""), "JSON must not contain an 'old' field"); + } + + private void assertPayload(JsonNode newNode, String createdId, Link link) { + assertNotNull(newNode); + assertEquals(createdId, newNode.get("id").asString()); + assertEquals("Kafka title", newNode.get("title").asString()); + assertEquals("Kafka details", newNode.get("content").asString()); + assertEquals(NOTE_TYPE_ID.toString(), newNode.get("typeId").asString()); + assertEquals(USER_ID.toString(), newNode.get("metadata").get("createdByUserId").asString()); + + var links = newNode.get("links"); + assertEquals(1, links.size()); + assertEquals(ORDER_LINE_TYPE, links.get(0).get("type").asString()); + assertEquals(link.getId(), links.get(0).get("id").asString()); + } +} + + + diff --git a/src/test/java/org/folio/notes/support/TestKafkaConsumer.java b/src/test/java/org/folio/notes/support/TestKafkaConsumer.java new file mode 100644 index 00000000..fb7cd226 --- /dev/null +++ b/src/test/java/org/folio/notes/support/TestKafkaConsumer.java @@ -0,0 +1,143 @@ +package org.folio.notes.support; + +import static org.apache.kafka.clients.consumer.ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG; +import static org.apache.kafka.clients.consumer.ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG; +import static org.awaitility.Awaitility.await; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.io.Closeable; +import java.time.Duration; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import org.apache.kafka.clients.admin.Admin; +import org.apache.kafka.clients.admin.NewTopic; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.apache.kafka.common.errors.TopicExistsException; +import org.apache.kafka.common.serialization.StringDeserializer; +import org.springframework.boot.kafka.autoconfigure.KafkaProperties; +import org.springframework.kafka.core.DefaultKafkaConsumerFactory; +import org.springframework.kafka.listener.ContainerProperties; +import org.springframework.kafka.listener.KafkaMessageListenerContainer; +import org.springframework.kafka.listener.MessageListener; + +/** + * Reusable test consumer that subscribes to a single Kafka topic with a raw {@code String} value deserializer and + * buffers every received record. Integration tests use it to assert on the JSON payload of published domain events. + * + *

The consumer owns its listener container and the record buffer; callers only need to + * {@link #subscribe(String, KafkaProperties)} it, {@link #poll()} the buffered records, and {@link #close()} it when + * done (it is {@link Closeable}, so it also works with try-with-resources or an {@code @AfterEach} hook).

+ */ +public final class TestKafkaConsumer implements Closeable { + + private static final Duration DEFAULT_POLL_TIMEOUT = Duration.ofMinutes(1); + private static final Duration POLL_INTERVAL = Duration.ofSeconds(1); + + private final KafkaMessageListenerContainer container; + private final BlockingQueue> records = new LinkedBlockingQueue<>(); + + private TestKafkaConsumer(KafkaMessageListenerContainer container) { + this.container = container; + } + + /** + * Creates and starts a consumer subscribed to the given topic. + * + * @param topic the topic to consume from (already env/tenant qualified) + * @param properties Spring Kafka properties (bootstrap servers point at the embedded broker) + * @return a started consumer; close it when done + */ + public static TestKafkaConsumer subscribe(String topic, KafkaProperties properties) { + createTopic(topic, properties); + properties.getConsumer().setGroupId("mod-notes-test-group"); + properties.getConsumer().setAutoOffsetReset("earliest"); + Map config = new HashMap<>(properties.buildConsumerProperties()); + config.put(KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); + config.put(VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); + + var consumerFactory = new DefaultKafkaConsumerFactory<>(config, new StringDeserializer(), new StringDeserializer()); + var containerProperties = new ContainerProperties(topic); + var container = new KafkaMessageListenerContainer<>(consumerFactory, containerProperties); + + var consumer = new TestKafkaConsumer(container); + container.setupMessageListener((MessageListener) consumer.records::add); + container.start(); + return consumer; + } + + /** + * Eagerly creates the topic via an admin client so the producer does not race the broker's lazy auto-creation + * (which otherwise surfaces as {@code Topic ... not present in metadata} on the first send). + * + * @param topic the topic to create (no-op if it already exists) + * @param properties Spring Kafka properties (used for the bootstrap servers) + */ + private static void createTopic(String topic, KafkaProperties properties) { + try (var admin = Admin.create(properties.buildAdminProperties())) { + admin.createTopics(List.of(new NewTopic(topic, 1, (short) 1))).all().get(); + } catch (ExecutionException e) { + if (!(e.getCause() instanceof TopicExistsException)) { + throw new IllegalStateException("Failed to create test topic " + topic, e); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while creating test topic " + topic, e); + } + } + + /** + * Waits (up to one minute) for the next record and returns it. + * + * @return the received record + */ + public ConsumerRecord poll() { + return poll(DEFAULT_POLL_TIMEOUT); + } + + /** + * Waits up to {@code timeout} for the next record and returns it, failing the calling test if none arrives. + * + * @param timeout the maximum time to wait + * @return the received record + */ + public ConsumerRecord poll(Duration timeout) { + var holder = new AtomicReference>(); + await().pollInterval(POLL_INTERVAL).atMost(timeout) + .untilAsserted(() -> { + var record = records.poll(); + assertNotNull(record, "Expected a record on the topic"); + holder.set(record); + }); + return holder.get(); + } + + /** + * Returns the next record if one arrives within {@code timeout}, or {@code null} otherwise (no assertion). Useful for + * verifying that no event was published. + * + * @param timeout the maximum time to wait + * @return the received record, or {@code null} if none arrived + */ + public ConsumerRecord pollNullable(Duration timeout) { + try { + return records.poll(timeout.toMillis(), TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while polling for records", e); + } + } + + @Override + public void close() { + container.stop(); + } +} + + + From 555c47cb89c8d3f0faa5015388e641e7f1fbaf5a Mon Sep 17 00:00:00 2001 From: saba_zedginidze Date: Mon, 20 Jul 2026 13:54:39 +0400 Subject: [PATCH 07/12] [MODNOTES-295] Update local setup to include kafka --- docker/.env | 12 ++++++++ docker/README.md | 18 ++++++++++++ docker/app-docker-compose.yml | 5 ++++ docker/infra-docker-compose.yml | 49 +++++++++++++++++++++++++++++++++ docker/kafka-init.sh | 34 +++++++++++++++++++++++ 5 files changed, 118 insertions(+) create mode 100644 docker/kafka-init.sh diff --git a/docker/.env b/docker/.env index bc38ca40..5bc33870 100644 --- a/docker/.env +++ b/docker/.env @@ -22,3 +22,15 @@ PGADMIN_DEFAULT_PASSWORD=admin WIREMOCK_PORT=9130 OKAPI_URL=http://wiremock:8080 +# Kafka configuration +ENV=folio +KAFKA_HOST=kafka +KAFKA_PORT=9093 +KAFKA_EXTERNAL_PORT=9092 +# Kafka UI (web) port -> http://localhost:8090 +KAFKA_UI_PORT=8090 +# Topics pre-created by kafka-topic-init (comma-separated tenants) and their partition count +KAFKA_INIT_TENANTS=diku,test +KAFKA_TOPIC_PARTITIONS=1 + + diff --git a/docker/README.md b/docker/README.md index c784caa7..439fa568 100644 --- a/docker/README.md +++ b/docker/README.md @@ -34,6 +34,13 @@ Configuration is managed via the `.env` file in this directory. | `PGADMIN_PORT` | PgAdmin port | `5050` | | `WIREMOCK_PORT` | WireMock (Okapi mock) port | `9130` | | `OKAPI_URL` | Okapi URL for the module | `http://wiremock:8080` | +| `ENV` | Kafka topic prefix | `folio` | +| `KAFKA_HOST` | Broker host (in-cluster) | `kafka` | +| `KAFKA_PORT` | Broker port (in-cluster) | `9093` | +| `KAFKA_EXTERNAL_PORT` | Broker port from host/IDE | `9092` | +| `KAFKA_UI_PORT` | Kafka UI web port | `8090` | +| `KAFKA_INIT_TENANTS` | Tenants to pre-create topics | `diku,test` | +| `KAFKA_TOPIC_PARTITIONS` | Partitions per topic | `1` | ## 🚀 Services @@ -54,6 +61,17 @@ Configuration is managed via the `.env` file in this directory. - **Access**: http://localhost:9130 (configurable via `WIREMOCK_PORT`) - **Mappings**: Located in `src/test/resources/mappings` +### Kafka +- **Purpose**: Message broker the module publishes Note domain events to +- **Image**: `apache/kafka-native` (KRaft mode, no Zookeeper) +- **Access (host/IDE)**: `localhost:9092` (configurable via `KAFKA_EXTERNAL_PORT`) +- **Access (in-cluster)**: `kafka:9093` +- **Topics**: `{ENV}.{tenant}.notes.note` — e.g. `folio.diku.notes.note`. Pre-created for the tenants in `KAFKA_INIT_TENANTS`; the broker also auto-creates the topic on first publish. + +### Kafka UI +- **Purpose**: Web UI to browse topics and inspect published event messages +- **Access**: http://localhost:8090 (configurable via `KAFKA_UI_PORT`) + ## 📖 Usage > **Note**: All commands in this guide assume you are in the `docker/` directory. If you're at the project root, run `cd docker` first. diff --git a/docker/app-docker-compose.yml b/docker/app-docker-compose.yml index 3b9d6d60..1f03ba97 100644 --- a/docker/app-docker-compose.yml +++ b/docker/app-docker-compose.yml @@ -14,6 +14,9 @@ services: - "${MODULE_PORT}:8081" - "${DEBUG_PORT}:5005" environment: + ENV: ${ENV} + KAFKA_HOST: ${KAFKA_HOST} + KAFKA_PORT: ${KAFKA_PORT} DB_HOST: ${DB_HOST} DB_PORT: ${DB_PORT} DB_DATABASE: ${DB_DATABASE} @@ -26,6 +29,8 @@ services: depends_on: - postgres - wiremock + - kafka + - kafka-topic-init deploy: replicas: ${MODULE_REPLICAS:-1} restart_policy: diff --git a/docker/infra-docker-compose.yml b/docker/infra-docker-compose.yml index 005e7f10..5a611df7 100644 --- a/docker/infra-docker-compose.yml +++ b/docker/infra-docker-compose.yml @@ -45,6 +45,55 @@ services: networks: - mod-notes-local + kafka: + image: apache/kafka-native + networks: + - mod-notes-local + ports: + - ${KAFKA_EXTERNAL_PORT}:${KAFKA_EXTERNAL_PORT} + - ${KAFKA_PORT}:9093 + environment: + KAFKA_LISTENERS: CONTROLLER://localhost:9091,HOST://0.0.0.0:${KAFKA_EXTERNAL_PORT},DOCKER://0.0.0.0:9093 + KAFKA_ADVERTISED_LISTENERS: HOST://localhost:${KAFKA_EXTERNAL_PORT},DOCKER://kafka:9093 + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,DOCKER:PLAINTEXT,HOST:PLAINTEXT + KAFKA_NODE_ID: 1 + KAFKA_PROCESS_ROLES: broker,controller + KAFKA_CONTROLLER_QUORUM_VOTERS: 1@localhost:9091 + KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER + KAFKA_INTER_BROKER_LISTENER_NAME: DOCKER + KAFKA_LOG_DIRS: /tmp/kraft-combined-logs + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 + KAFKA_AUTO_CREATE_TOPICS_ENABLE: 'true' + + kafka-ui: + image: provectuslabs/kafka-ui:latest + networks: + - mod-notes-local + ports: + - ${KAFKA_UI_PORT}:8080 + environment: + DYNAMIC_CONFIG_ENABLED: 'true' + KAFKA_CLUSTERS_0_NAME: local + KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka:9093 + depends_on: + - kafka + + kafka-topic-init: + image: apache/kafka:latest + depends_on: + - kafka + environment: + KAFKA_HOST: kafka + KAFKA_PORT: 9093 + ENV: ${ENV} + TENANTS: ${KAFKA_INIT_TENANTS} + KAFKA_TOPIC_PARTITIONS: ${KAFKA_TOPIC_PARTITIONS} + volumes: + - ./kafka-init.sh:/usr/bin/kafka-init.sh + entrypoint: ["bash", "/usr/bin/kafka-init.sh"] + networks: + - mod-notes-local + networks: mod-notes-local: driver: bridge diff --git a/docker/kafka-init.sh b/docker/kafka-init.sh new file mode 100644 index 00000000..98d7635b --- /dev/null +++ b/docker/kafka-init.sh @@ -0,0 +1,34 @@ +#!/bin/bash +set -e + +# Pre-creates the Note domain-event topics for mod-notes so they are visible in Kafka UI straight away +KAFKA_BROKER="${KAFKA_HOST}:${KAFKA_PORT}" +echo "Waiting for Kafka broker at $KAFKA_BROKER..." + +until /opt/kafka/bin/kafka-broker-api-versions.sh --bootstrap-server "$KAFKA_BROKER" > /dev/null 2>&1; do + echo "Kafka broker not ready yet, waiting..." + sleep 2 +done + +echo "Kafka broker is ready!" + +KAFKA_TOPICS_CMD="/opt/kafka/bin/kafka-topics.sh" + +# Comma-separated list of tenants (from the TENANTS env var), default "diku,test". +IFS=',' read -ra TENANT_LIST <<< "${TENANTS:-diku,test}" + +for TENANT in "${TENANT_LIST[@]}"; do + TENANT_TRIMMED=$(echo "$TENANT" | xargs) + TOPIC="${ENV}.${TENANT_TRIMMED}.notes.note" + $KAFKA_TOPICS_CMD \ + --create \ + --bootstrap-server "$KAFKA_BROKER" \ + --replication-factor 1 \ + --partitions "${KAFKA_TOPIC_PARTITIONS}" \ + --topic "$TOPIC" \ + --if-not-exists + echo "Created topic: $TOPIC" +done + +echo "Kafka topics created successfully." + From 1c855b479d3e44eb1e82cf221b1b276b4316347d Mon Sep 17 00:00:00 2001 From: saba_zedginidze Date: Mon, 20 Jul 2026 18:21:36 +0400 Subject: [PATCH 08/12] [MODNOTES-295] Update module descriptor --- README.md | 6 +++++- descriptors/ModuleDescriptor-template.json | 12 ++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 82296338..cc0436f9 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ quite do yet. ### Compiling -Compile with +Compile with ```shell mvn clean install ``` @@ -71,6 +71,10 @@ requires and provides, the permissions, and the additional module metadata. Use `DB_HOST`, `DB_PORT`, `DB_DATABASE`, `DB_USERNAME`, `DB_PASSWORD` to configure the PostgreSQL database. +Use `KAFKA_HOST` (default `localhost`) and `KAFKA_PORT` (default `9092`) to configure the Kafka broker the module +publishes domain events to. `ENV` (default `folio`) is the environment prefix used when building the tenant-scoped topic +name `{ENV}.{tenant}.notes.note`. + `NOTES_TYPES_DEFAULTS_LIMIT` defaults to 25. `MAX_RECORDS_COUNT` defaults to 1000. diff --git a/descriptors/ModuleDescriptor-template.json b/descriptors/ModuleDescriptor-template.json index 9ea8fef4..f794f230 100644 --- a/descriptors/ModuleDescriptor-template.json +++ b/descriptors/ModuleDescriptor-template.json @@ -313,6 +313,18 @@ { "name": "NOTES_TYPES_DEFAULTS_LIMIT", "value": "25" + }, + { + "name": "KAFKA_HOST", + "value": "kafka" + }, + { + "name": "KAFKA_PORT", + "value": "9092" + }, + { + "name": "ENV", + "value": "folio" } ] } From 222693d6c88c50e0f31b9fd85cc711b301f6f600 Mon Sep 17 00:00:00 2001 From: saba_zedginidze Date: Wed, 22 Jul 2026 13:35:01 +0400 Subject: [PATCH 09/12] [MODNOTES-295] Address comments --- descriptors/ModuleDescriptor-template.json | 33 ++++- docker/.env | 3 - docker/README.md | 4 +- docker/app-docker-compose.yml | 1 - docker/infra-docker-compose.yml | 15 -- docker/kafka-init.sh | 34 ----- pom.xml | 6 + .../integration/kafka/NoteEventProducer.java | 42 ++++-- .../notes/service/impl/NoteTenantService.java | 15 +- .../notes/service/impl/NotesServiceImpl.java | 6 +- src/main/resources/application.yaml | 4 +- .../notes/controller/NoteDomainEventIT.java | 129 ------------------ .../notes/controller/NotesControllerIT.java | 71 ++++++++++ .../notes/service/NoteTenantServiceTest.java | 21 ++- .../notes/support/TestKafkaConsumer.java | 38 ++---- 15 files changed, 191 insertions(+), 231 deletions(-) delete mode 100644 docker/kafka-init.sh delete mode 100644 src/test/java/org/folio/notes/controller/NoteDomainEventIT.java diff --git a/descriptors/ModuleDescriptor-template.json b/descriptors/ModuleDescriptor-template.json index f794f230..5e41f685 100644 --- a/descriptors/ModuleDescriptor-template.json +++ b/descriptors/ModuleDescriptor-template.json @@ -322,9 +322,40 @@ "name": "KAFKA_PORT", "value": "9092" }, + { + "name": "KAFKA_SECURITY_PROTOCOL", + "value": "PLAINTEXT", + "description": "Kafka security protocol used to communicate with brokers (SSL or PLAINTEXT)" + }, + { + "name": "KAFKA_SSL_KEYSTORE_LOCATION", + "description": "The location of the Kafka key store file. This is optional for client and can be used for two-way authentication for client." + }, + { + "name": "KAFKA_SSL_KEYSTORE_PASSWORD", + "description": "The store password for the Kafka key store file. This is optional for client and only needed if 'ssl.keystore.location' is configured." + }, + { + "name": "KAFKA_SSL_TRUSTSTORE_LOCATION", + "description": "The location of the Kafka trust store file." + }, + { + "name": "KAFKA_SSL_TRUSTSTORE_PASSWORD", + "description": "The password for the Kafka trust store file. If a password is not set, trust store file configured will still be used, but integrity checking is disabled." + }, + { + "name": "KAFKA_NOTE_TOPIC_PARTITIONS", + "value": "1", + "description": "Number of partitions for the `notes.note` topic." + }, + { + "name": "KAFKA_NOTE_TOPIC_REPLICATION_FACTOR", + "description": "Replication factor for the `notes.note` topic. When not set, the broker default is used." + }, { "name": "ENV", - "value": "folio" + "value": "folio", + "description": "The logical name of the deployment, must be unique across all environments using the same shared Kafka/Elasticsearch clusters, `a-z (any case)`, `0-9`, `-`, `_` symbols only allowed" } ] } diff --git a/docker/.env b/docker/.env index 5bc33870..cff3c0f9 100644 --- a/docker/.env +++ b/docker/.env @@ -29,8 +29,5 @@ KAFKA_PORT=9093 KAFKA_EXTERNAL_PORT=9092 # Kafka UI (web) port -> http://localhost:8090 KAFKA_UI_PORT=8090 -# Topics pre-created by kafka-topic-init (comma-separated tenants) and their partition count -KAFKA_INIT_TENANTS=diku,test -KAFKA_TOPIC_PARTITIONS=1 diff --git a/docker/README.md b/docker/README.md index 439fa568..00e46337 100644 --- a/docker/README.md +++ b/docker/README.md @@ -39,8 +39,6 @@ Configuration is managed via the `.env` file in this directory. | `KAFKA_PORT` | Broker port (in-cluster) | `9093` | | `KAFKA_EXTERNAL_PORT` | Broker port from host/IDE | `9092` | | `KAFKA_UI_PORT` | Kafka UI web port | `8090` | -| `KAFKA_INIT_TENANTS` | Tenants to pre-create topics | `diku,test` | -| `KAFKA_TOPIC_PARTITIONS` | Partitions per topic | `1` | ## 🚀 Services @@ -66,7 +64,7 @@ Configuration is managed via the `.env` file in this directory. - **Image**: `apache/kafka-native` (KRaft mode, no Zookeeper) - **Access (host/IDE)**: `localhost:9092` (configurable via `KAFKA_EXTERNAL_PORT`) - **Access (in-cluster)**: `kafka:9093` -- **Topics**: `{ENV}.{tenant}.notes.note` — e.g. `folio.diku.notes.note`. Pre-created for the tenants in `KAFKA_INIT_TENANTS`; the broker also auto-creates the topic on first publish. +- **Topics**: `{ENV}.{tenant}.notes.note` — e.g. `folio.diku.notes.note`. The module creates the topic when a tenant is enabled (via the `/_/tenant` API); the broker also auto-creates it on first publish. ### Kafka UI - **Purpose**: Web UI to browse topics and inspect published event messages diff --git a/docker/app-docker-compose.yml b/docker/app-docker-compose.yml index 1f03ba97..f51cc7bf 100644 --- a/docker/app-docker-compose.yml +++ b/docker/app-docker-compose.yml @@ -30,7 +30,6 @@ services: - postgres - wiremock - kafka - - kafka-topic-init deploy: replicas: ${MODULE_REPLICAS:-1} restart_policy: diff --git a/docker/infra-docker-compose.yml b/docker/infra-docker-compose.yml index 5a611df7..de07c3bd 100644 --- a/docker/infra-docker-compose.yml +++ b/docker/infra-docker-compose.yml @@ -78,21 +78,6 @@ services: depends_on: - kafka - kafka-topic-init: - image: apache/kafka:latest - depends_on: - - kafka - environment: - KAFKA_HOST: kafka - KAFKA_PORT: 9093 - ENV: ${ENV} - TENANTS: ${KAFKA_INIT_TENANTS} - KAFKA_TOPIC_PARTITIONS: ${KAFKA_TOPIC_PARTITIONS} - volumes: - - ./kafka-init.sh:/usr/bin/kafka-init.sh - entrypoint: ["bash", "/usr/bin/kafka-init.sh"] - networks: - - mod-notes-local networks: mod-notes-local: diff --git a/docker/kafka-init.sh b/docker/kafka-init.sh deleted file mode 100644 index 98d7635b..00000000 --- a/docker/kafka-init.sh +++ /dev/null @@ -1,34 +0,0 @@ -#!/bin/bash -set -e - -# Pre-creates the Note domain-event topics for mod-notes so they are visible in Kafka UI straight away -KAFKA_BROKER="${KAFKA_HOST}:${KAFKA_PORT}" -echo "Waiting for Kafka broker at $KAFKA_BROKER..." - -until /opt/kafka/bin/kafka-broker-api-versions.sh --bootstrap-server "$KAFKA_BROKER" > /dev/null 2>&1; do - echo "Kafka broker not ready yet, waiting..." - sleep 2 -done - -echo "Kafka broker is ready!" - -KAFKA_TOPICS_CMD="/opt/kafka/bin/kafka-topics.sh" - -# Comma-separated list of tenants (from the TENANTS env var), default "diku,test". -IFS=',' read -ra TENANT_LIST <<< "${TENANTS:-diku,test}" - -for TENANT in "${TENANT_LIST[@]}"; do - TENANT_TRIMMED=$(echo "$TENANT" | xargs) - TOPIC="${ENV}.${TENANT_TRIMMED}.notes.note" - $KAFKA_TOPICS_CMD \ - --create \ - --bootstrap-server "$KAFKA_BROKER" \ - --replication-factor 1 \ - --partitions "${KAFKA_TOPIC_PARTITIONS}" \ - --topic "$TOPIC" \ - --if-not-exists - echo "Created topic: $TOPIC" -done - -echo "Kafka topics created successfully." - diff --git a/pom.xml b/pom.xml index 05cfc563..e3c3de2c 100644 --- a/pom.xml +++ b/pom.xml @@ -27,6 +27,7 @@ UTF-8 10.0.0 + 6.0.0 1.6.3 1.22.2 1.18.44 @@ -64,6 +65,11 @@ folio-spring-cql ${folio-spring-support.version}
+ + org.folio + folio-service-tools-spring-dev + ${folio-service-tools.version} + org.springframework.boot spring-boot-starter-cache diff --git a/src/main/java/org/folio/notes/integration/kafka/NoteEventProducer.java b/src/main/java/org/folio/notes/integration/kafka/NoteEventProducer.java index 29ac3b1e..30c9be9c 100644 --- a/src/main/java/org/folio/notes/integration/kafka/NoteEventProducer.java +++ b/src/main/java/org/folio/notes/integration/kafka/NoteEventProducer.java @@ -1,11 +1,18 @@ package org.folio.notes.integration.kafka; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; import java.util.UUID; import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.producer.ProducerRecord; +import org.apache.kafka.common.header.Header; +import org.apache.kafka.common.header.internals.RecordHeader; import org.folio.notes.domain.dto.Note; import org.folio.notes.domain.event.DomainEvent; import org.folio.spring.FolioExecutionContext; -import org.springframework.beans.factory.annotation.Value; +import org.folio.spring.tools.kafka.FolioKafkaProperties; +import org.folio.spring.tools.kafka.KafkaUtils; import org.springframework.kafka.core.KafkaTemplate; import org.springframework.stereotype.Component; @@ -13,26 +20,28 @@ * Publishes Note {@link DomainEvent}s to the tenant-scoped Kafka topic. * *

The topic name follows the FOLIO convention {@code {env}.{tenant}.notes.note} (for example - * {@code folio.diku.notes.note}). The tenant is resolved at publish time via {@link FolioExecutionContext} - * and {@code env} defaults to {@code folio}.

+ * {@code folio.diku.notes.note}), resolved via {@link KafkaUtils#getTenantTopicName(String, String)} the same way the + * topic is created on tenant enable. The tenant is resolved at publish time via {@link FolioExecutionContext}.

+ * + *

Every message carries headers ({@code X-Okapi-Tenant}, {@code eventType}, {@code domain}) so consumers can + * filter by tenant, event type or Note domain without deserializing the JSON payload.

*/ @Slf4j @Component public class NoteEventProducer { + private static final String EVENT_TYPE_HEADER = "domain-event-type"; + private final KafkaTemplate> kafkaTemplate; private final FolioExecutionContext context; - private final String env; private final String noteTopic; public NoteEventProducer(KafkaTemplate> kafkaTemplate, FolioExecutionContext context, - @Value("${folio.environment:folio}") String env, - @Value("${folio.kafka.topics.note:notes.note}") String noteTopic) { + FolioKafkaProperties kafkaProperties) { this.kafkaTemplate = kafkaTemplate; this.context = context; - this.env = env; - this.noteTopic = noteTopic; + this.noteTopic = kafkaProperties.getTopics().getFirst().getName(); } /** @@ -42,17 +51,26 @@ public NoteEventProducer(KafkaTemplate> kafkaTemplate, * @param event the domain event envelope to publish */ public void publish(UUID id, DomainEvent event) { - var topic = topicName(); + var topic = KafkaUtils.getTenantTopicName(noteTopic, context.getTenantId()); try { log.debug("publish:: sending Note event [topic: {}, id: {}, type: {}]", topic, id, event.getType()); - kafkaTemplate.send(topic, id, event); + var producerRecord = new ProducerRecord<>(topic, null, id, event, buildHeaders(event)); + kafkaTemplate.send(producerRecord); log.info("publish:: Note event sent [topic: {}, id: {}, type: {}]", topic, id, event.getType()); } catch (Exception e) { log.error("publish:: failed to send Note event [topic: {}, id: {}, type: {}]", topic, id, event.getType(), e); } } - private String topicName() { - return String.format("%s.%s.%s", env, context.getTenantId(), noteTopic); + private List
buildHeaders(DomainEvent event) { + var headers = new ArrayList
(); + headers.add(header(EVENT_TYPE_HEADER, event.getType().name())); + context.getAllHeaders().forEach((key, value) -> headers.add(header(key, value.iterator().next()))); + return headers; } + + private Header header(String key, String value) { + return new RecordHeader(key, value == null ? null : value.getBytes(StandardCharsets.UTF_8)); + } + } diff --git a/src/main/java/org/folio/notes/service/impl/NoteTenantService.java b/src/main/java/org/folio/notes/service/impl/NoteTenantService.java index 4bf01817..7852a233 100644 --- a/src/main/java/org/folio/notes/service/impl/NoteTenantService.java +++ b/src/main/java/org/folio/notes/service/impl/NoteTenantService.java @@ -1,29 +1,42 @@ package org.folio.notes.service.impl; +import lombok.extern.slf4j.Slf4j; import org.folio.notes.service.NoteTypesService; import org.folio.spring.FolioExecutionContext; import org.folio.spring.liquibase.FolioSpringLiquibase; import org.folio.spring.service.TenantService; +import org.folio.spring.tools.kafka.KafkaAdminService; +import org.folio.tenant.domain.dto.TenantAttributes; import org.springframework.context.annotation.Primary; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; @Service @Primary +@Slf4j public class NoteTenantService extends TenantService { private final NoteTypesService noteTypesService; + private final KafkaAdminService kafkaAdminService; public NoteTenantService(JdbcTemplate jdbcTemplate, FolioExecutionContext context, FolioSpringLiquibase folioSpringLiquibase, - NoteTypesService noteTypesService) { + NoteTypesService noteTypesService, + KafkaAdminService kafkaAdminService) { super(jdbcTemplate, context, folioSpringLiquibase); this.noteTypesService = noteTypesService; + this.kafkaAdminService = kafkaAdminService; } @Override public void loadReferenceData() { noteTypesService.populateDefaultType(); } + + @Override + protected void afterTenantUpdate(TenantAttributes tenantAttributes) { + super.afterTenantUpdate(tenantAttributes); + kafkaAdminService.createTopics(context.getTenantId()); + } } diff --git a/src/main/java/org/folio/notes/service/impl/NotesServiceImpl.java b/src/main/java/org/folio/notes/service/impl/NotesServiceImpl.java index 8b4a0788..00c91e44 100644 --- a/src/main/java/org/folio/notes/service/impl/NotesServiceImpl.java +++ b/src/main/java/org/folio/notes/service/impl/NotesServiceImpl.java @@ -133,9 +133,9 @@ public Note createNote(Note note) { NoteEntity entity = saveNote(note, dto -> initNewEntity(notesMapper.toEntity(dto))); log.info("createNote:: created note by title: {}, domain: {}, type: {}", note.getTitle(), note.getDomain(), note.getType()); - Note newSnapshot = notesMapper.toDto(entity); - domainEventPublisherService.publishNoteCreatedEvent(newSnapshot); - return newSnapshot; + Note createdNote = notesMapper.toDto(entity); + domainEventPublisherService.publishNoteCreatedEvent(createdNote); + return createdNote; } @Transactional diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 6050dd93..a85d449a 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -50,7 +50,9 @@ folio: limit: ${MAX_RECORDS_COUNT:1000} kafka: topics: - note: notes.note + - name: notes.note + numPartitions: ${KAFKA_NOTE_TOPIC_PARTITIONS:1} + replicationFactor: ${KAFKA_NOTE_TOPIC_REPLICATION_FACTOR:} # Spring properties spring: diff --git a/src/test/java/org/folio/notes/controller/NoteDomainEventIT.java b/src/test/java/org/folio/notes/controller/NoteDomainEventIT.java deleted file mode 100644 index 0b2c1661..00000000 --- a/src/test/java/org/folio/notes/controller/NoteDomainEventIT.java +++ /dev/null @@ -1,129 +0,0 @@ -package org.folio.notes.controller; - -import static org.folio.notes.support.DatabaseHelper.LINK; -import static org.folio.notes.support.DatabaseHelper.NOTE; -import static org.folio.notes.support.DatabaseHelper.TYPE; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -import java.time.Duration; -import java.util.List; -import java.util.UUID; -import org.folio.notes.domain.dto.Link; -import org.folio.notes.domain.dto.Note; -import org.folio.notes.domain.dto.User; -import org.folio.notes.domain.entity.NoteTypeEntity; -import org.folio.notes.support.TestApiBase; -import org.folio.notes.support.TestKafkaConsumer; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.kafka.autoconfigure.KafkaProperties; -import tools.jackson.databind.JsonNode; - -@DisplayName("Note create domain events") -class NoteDomainEventIT extends TestApiBase { - - private static final String NOTE_URL = "/notes"; - private static final String NOTE_TOPIC = "folio.test.notes.note"; - private static final String ORDER_LINE_TYPE = "order-line"; - - private static final UUID NOTE_TYPE_ID = UUID.fromString("2af21797-d25b-46dc-8427-1759d1db2057"); - private static final String NOTE_TYPE_NAME = "General note"; - - @Autowired - private KafkaProperties kafkaProperties; - - private TestKafkaConsumer consumer; - - @BeforeEach - void setUp() { - stubUser(new User(USER_ID, "test_user", null)); - databaseHelper.clearTable(TENANT, NOTE); - databaseHelper.clearTable(TENANT, LINK); - databaseHelper.clearTable(TENANT, TYPE); - - var noteType = new NoteTypeEntity(); - noteType.setId(NOTE_TYPE_ID); - noteType.setName(NOTE_TYPE_NAME); - noteType.setCreatedBy(USER_ID); - databaseHelper.saveNoteType(noteType, TENANT); - - consumer = TestKafkaConsumer.subscribe(NOTE_TOPIC, kafkaProperties); - } - - @AfterEach - void tearDown() { - if (consumer != null) { - consumer.close(); - } - } - - @Test - @DisplayName("create publishes a CREATE event with the full snapshot and no 'old' field") - void createNote_publishesCreateEvent() throws Exception { - var link = new Link().id(UUID.randomUUID().toString()).type(ORDER_LINE_TYPE); - var note = new Note() - .title("Kafka title") - .content("Kafka details") - .domain("orders") - .typeId(NOTE_TYPE_ID) - .links(List.of(link)); - - var response = mockMvc.perform(post(NOTE_URL).headers(okapiHeaders()).content(asJsonString(note))) - .andExpect(status().isCreated()) - .andReturn().getResponse().getContentAsString(); - - var event = consumer.poll(); - var envelope = OBJECT_MAPPER.readTree(event.value()); - - assertEnvelope(envelope, event.value()); - assertPayload(envelope.get("new"), OBJECT_MAPPER.readTree(response).get("id").asString(), link); - } - - @Test - @DisplayName("invalid payload (missing title) publishes no event") - void createNote_invalidPayload_publishesNoEvent() throws Exception { - var note = new Note() - .domain("orders") - .typeId(NOTE_TYPE_ID) - .links(List.of(new Link().id(UUID.randomUUID().toString()).type(ORDER_LINE_TYPE))); - - mockMvc.perform(post(NOTE_URL).headers(okapiHeaders()).content(asJsonString(note))) - .andExpect(status().is4xxClientError()); - - var event = consumer.pollNullable(Duration.ofSeconds(10)); - assertNull(event, "No event should be published for an invalid create"); - } - - private void assertEnvelope(JsonNode envelope, String rawJson) { - assertNotNull(envelope.get("id"), "eventId (id) must be present"); - assertEquals("CREATE", envelope.get("type").asString()); - assertEquals(TENANT, envelope.get("tenant").asString()); - assertNull(envelope.get("old"), "CREATE event must not carry an 'old' field"); - assertFalse(rawJson.contains("\"old\""), "JSON must not contain an 'old' field"); - } - - private void assertPayload(JsonNode newNode, String createdId, Link link) { - assertNotNull(newNode); - assertEquals(createdId, newNode.get("id").asString()); - assertEquals("Kafka title", newNode.get("title").asString()); - assertEquals("Kafka details", newNode.get("content").asString()); - assertEquals(NOTE_TYPE_ID.toString(), newNode.get("typeId").asString()); - assertEquals(USER_ID.toString(), newNode.get("metadata").get("createdByUserId").asString()); - - var links = newNode.get("links"); - assertEquals(1, links.size()); - assertEquals(ORDER_LINE_TYPE, links.get(0).get("type").asString()); - assertEquals(link.getId(), links.get(0).get("id").asString()); - } -} - - - diff --git a/src/test/java/org/folio/notes/controller/NotesControllerIT.java b/src/test/java/org/folio/notes/controller/NotesControllerIT.java index dda567d6..fdf6be00 100644 --- a/src/test/java/org/folio/notes/controller/NotesControllerIT.java +++ b/src/test/java/org/folio/notes/controller/NotesControllerIT.java @@ -14,6 +14,8 @@ import static org.hamcrest.Matchers.not; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.params.provider.Arguments.arguments; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; @@ -44,6 +46,7 @@ import org.folio.notes.domain.entity.NoteTypeEntity; import org.folio.notes.exception.NoteNotFoundException; import org.folio.notes.support.TestApiBase; +import org.folio.notes.support.TestKafkaConsumer; import org.folio.spring.cql.CqlQueryValidationException; import org.hamcrest.Matcher; import org.hamcrest.MatcherAssert; @@ -55,13 +58,16 @@ import org.junit.jupiter.params.provider.CsvSource; import org.junit.jupiter.params.provider.MethodSource; import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.kafka.autoconfigure.KafkaProperties; import org.springframework.http.HttpHeaders; import org.springframework.test.context.TestPropertySource; import org.springframework.test.web.servlet.ResultMatcher; import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import tools.jackson.databind.JsonNode; @TestPropertySource(properties = {"folio.notes.types.defaults.limit=5"}) class NotesControllerIT extends TestApiBase { @@ -88,8 +94,15 @@ class NotesControllerIT extends TestApiBase { private static final UUID[] TYPE_IDS = new UUID[] {randomUUID(), randomUUID()}; private static final int DEFAULT_LINK_AMOUNT = 1; + private static final String NOTE_EVENT_TOPIC = "folio.test.notes.note"; + private static final String ORDER_LINE_TYPE = "order-line"; + private static final UUID EVENT_NOTE_TYPE_ID = UUID.fromString("2af21797-d25b-46dc-8427-1759d1db2057"); + private static final String EVENT_NOTE_TYPE_NAME = "General note"; + @Value("${folio.notes.types.defaults.limit}") private String defaultNoteTypeLimit; + @Autowired + private KafkaProperties kafkaProperties; @BeforeEach void setUp() { @@ -1065,6 +1078,64 @@ private NoteEntity generateNoteEntityWithParams(String title, String typeId, Str return noteEntity; } + // Tests for domain events + @Test + @DisplayName("create publishes a CREATE event with the full snapshot and no 'old' field") + void createNote_publishesCreateEvent() throws Exception { + saveEventNoteType(); + var link = new Link().id(UUID.randomUUID().toString()).type(ORDER_LINE_TYPE); + var note = new Note() + .title("Kafka title") + .content("Kafka details") + .domain("orders") + .typeId(EVENT_NOTE_TYPE_ID) + .links(List.of(link)); + + try (var consumer = TestKafkaConsumer.subscribe(NOTE_EVENT_TOPIC, kafkaProperties)) { + var response = mockMvc.perform(postNote(note)) + .andExpect(status().isCreated()) + .andReturn().getResponse().getContentAsString(); + var newNoteId = OBJECT_MAPPER.readTree(response).get("id").asString(); + + var event = consumer.poll(newNoteId); + assertNotNull(event); + + var envelope = OBJECT_MAPPER.readTree(event.value()); + assertEventEnvelope(envelope, event.value()); + assertEventPayload(envelope.get("new"), newNoteId, link); + } + } + + private void saveEventNoteType() { + var noteType = new NoteTypeEntity(); + noteType.setId(EVENT_NOTE_TYPE_ID); + noteType.setName(EVENT_NOTE_TYPE_NAME); + noteType.setCreatedBy(USER_ID); + databaseHelper.saveNoteType(noteType, TENANT); + } + + private void assertEventEnvelope(JsonNode envelope, String rawJson) { + assertNotNull(envelope.get("id"), "eventId (id) must be present"); + assertEquals("CREATE", envelope.get("type").asString()); + assertEquals(TENANT, envelope.get("tenant").asString()); + assertNull(envelope.get("old"), "CREATE event must not carry an 'old' field"); + assertFalse(rawJson.contains("\"old\""), "JSON must not contain an 'old' field"); + } + + private void assertEventPayload(JsonNode newNode, String createdId, Link link) { + assertNotNull(newNode); + assertEquals(createdId, newNode.get("id").asString()); + assertEquals("Kafka title", newNode.get("title").asString()); + assertEquals("Kafka details", newNode.get("content").asString()); + assertEquals(EVENT_NOTE_TYPE_ID.toString(), newNode.get("typeId").asString()); + assertEquals(USER_ID.toString(), newNode.get("metadata").get("createdByUserId").asString()); + + var links = newNode.get("links"); + assertEquals(1, links.size()); + assertEquals(ORDER_LINE_TYPE, links.get(0).get("type").asString()); + assertEquals(link.getId(), links.get(0).get("id").asString()); + } + private Note generateNote() throws Exception { var noteType = new NoteType().name(insecure().nextAlphabetic(100)); var notyTypeAsString = mockMvc.perform(postNoteType(noteType)).andExpect(status().isCreated()) diff --git a/src/test/java/org/folio/notes/service/NoteTenantServiceTest.java b/src/test/java/org/folio/notes/service/NoteTenantServiceTest.java index eb6c3816..4e9bab1c 100644 --- a/src/test/java/org/folio/notes/service/NoteTenantServiceTest.java +++ b/src/test/java/org/folio/notes/service/NoteTenantServiceTest.java @@ -3,23 +3,40 @@ import static org.mockito.Mockito.verify; import org.folio.notes.service.impl.NoteTenantService; +import org.folio.spring.FolioExecutionContext; +import org.folio.spring.liquibase.FolioSpringLiquibase; import org.folio.spring.testing.type.UnitTest; +import org.folio.spring.tools.kafka.KafkaAdminService; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.jdbc.core.JdbcTemplate; @UnitTest @ExtendWith(MockitoExtension.class) class NoteTenantServiceTest { + @Mock + private JdbcTemplate jdbcTemplate; + @Mock + private FolioExecutionContext context; + @Mock + private FolioSpringLiquibase folioSpringLiquibase; @Mock private NoteTypesService noteTypesService; + @Mock + private KafkaAdminService kafkaAdminService; - @InjectMocks private NoteTenantService tenantService; + @BeforeEach + void setUp() { + tenantService = new NoteTenantService(jdbcTemplate, context, folioSpringLiquibase, noteTypesService, + kafkaAdminService); + } + @Test void shouldPopulateDefaultTypeOnLoadReferenceData() { tenantService.loadReferenceData(); diff --git a/src/test/java/org/folio/notes/support/TestKafkaConsumer.java b/src/test/java/org/folio/notes/support/TestKafkaConsumer.java index fb7cd226..ccf67b9d 100644 --- a/src/test/java/org/folio/notes/support/TestKafkaConsumer.java +++ b/src/test/java/org/folio/notes/support/TestKafkaConsumer.java @@ -3,17 +3,17 @@ import static org.apache.kafka.clients.consumer.ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG; import static org.apache.kafka.clients.consumer.ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG; import static org.awaitility.Awaitility.await; -import static org.junit.jupiter.api.Assertions.assertNotNull; import java.io.Closeable; import java.time.Duration; +import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.concurrent.BlockingQueue; import java.util.concurrent.ExecutionException; import java.util.concurrent.LinkedBlockingQueue; -import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; import org.apache.kafka.clients.admin.Admin; import org.apache.kafka.clients.admin.NewTopic; @@ -31,7 +31,7 @@ * buffers every received record. Integration tests use it to assert on the JSON payload of published domain events. * *

The consumer owns its listener container and the record buffer; callers only need to - * {@link #subscribe(String, KafkaProperties)} it, {@link #poll()} the buffered records, and {@link #close()} it when + * {@link #subscribe(String, KafkaProperties)} it, {@link #poll(Duration)} the buffered records, and {@link #close()} it when * done (it is {@link Closeable}, so it also works with try-with-resources or an {@code @AfterEach} hook).

*/ public final class TestKafkaConsumer implements Closeable { @@ -96,8 +96,10 @@ private static void createTopic(String topic, KafkaProperties properties) { * * @return the received record */ - public ConsumerRecord poll() { - return poll(DEFAULT_POLL_TIMEOUT); + public ConsumerRecord poll(String key) { + return poll(DEFAULT_POLL_TIMEOUT).stream() + .filter(e -> Objects.equals(e.key(), key)).findFirst() + .orElse(null); } /** @@ -106,33 +108,17 @@ public ConsumerRecord poll() { * @param timeout the maximum time to wait * @return the received record */ - public ConsumerRecord poll(Duration timeout) { - var holder = new AtomicReference>(); + public List> poll(Duration timeout) { + var holder = new AtomicReference>>(); await().pollInterval(POLL_INTERVAL).atMost(timeout) .untilAsserted(() -> { - var record = records.poll(); - assertNotNull(record, "Expected a record on the topic"); - holder.set(record); + List> events = new ArrayList<>(); + records.drainTo(events); + holder.set(events); }); return holder.get(); } - /** - * Returns the next record if one arrives within {@code timeout}, or {@code null} otherwise (no assertion). Useful for - * verifying that no event was published. - * - * @param timeout the maximum time to wait - * @return the received record, or {@code null} if none arrived - */ - public ConsumerRecord pollNullable(Duration timeout) { - try { - return records.poll(timeout.toMillis(), TimeUnit.MILLISECONDS); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new IllegalStateException("Interrupted while polling for records", e); - } - } - @Override public void close() { container.stop(); From 3d9d8622823d7540c14fdef5a95ccac5b86a5f4b Mon Sep 17 00:00:00 2001 From: saba_zedginidze Date: Wed, 22 Jul 2026 13:48:17 +0400 Subject: [PATCH 10/12] [MODNOTES-295] Fix style --- .../org/folio/notes/integration/kafka/NoteEventProducer.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/org/folio/notes/integration/kafka/NoteEventProducer.java b/src/main/java/org/folio/notes/integration/kafka/NoteEventProducer.java index 30c9be9c..b1357223 100644 --- a/src/main/java/org/folio/notes/integration/kafka/NoteEventProducer.java +++ b/src/main/java/org/folio/notes/integration/kafka/NoteEventProducer.java @@ -72,5 +72,4 @@ private List
buildHeaders(DomainEvent event) { private Header header(String key, String value) { return new RecordHeader(key, value == null ? null : value.getBytes(StandardCharsets.UTF_8)); } - } From 58aace8c1cd4d3a41dab288b6071ba30f10ec499 Mon Sep 17 00:00:00 2001 From: saba_zedginidze Date: Wed, 22 Jul 2026 15:01:13 +0400 Subject: [PATCH 11/12] [MODNOTES-295] Fix style --- src/test/java/org/folio/notes/support/TestKafkaConsumer.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/java/org/folio/notes/support/TestKafkaConsumer.java b/src/test/java/org/folio/notes/support/TestKafkaConsumer.java index ccf67b9d..5b47d491 100644 --- a/src/test/java/org/folio/notes/support/TestKafkaConsumer.java +++ b/src/test/java/org/folio/notes/support/TestKafkaConsumer.java @@ -31,8 +31,8 @@ * buffers every received record. Integration tests use it to assert on the JSON payload of published domain events. * *

The consumer owns its listener container and the record buffer; callers only need to - * {@link #subscribe(String, KafkaProperties)} it, {@link #poll(Duration)} the buffered records, and {@link #close()} it when - * done (it is {@link Closeable}, so it also works with try-with-resources or an {@code @AfterEach} hook).

+ * {@link #subscribe(String, KafkaProperties)} it, {@link #poll(Duration)} the buffered records, and {@link #close()} + * it when done (it is {@link Closeable}, so it also works with try-with-resources or an {@code @AfterEach} hook).

*/ public final class TestKafkaConsumer implements Closeable { From a9a22adf7886c56fbe5863c80b12879a77bd5692 Mon Sep 17 00:00:00 2001 From: saba_zedginidze Date: Wed, 22 Jul 2026 15:46:12 +0400 Subject: [PATCH 12/12] [MODNOTES-295] Add missing domain header --- .../notes/integration/kafka/NoteEventProducer.java | 12 +++++++++++- .../folio/notes/service/NoteTenantServiceTest.java | 9 ++------- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/main/java/org/folio/notes/integration/kafka/NoteEventProducer.java b/src/main/java/org/folio/notes/integration/kafka/NoteEventProducer.java index b1357223..0b6c2c7c 100644 --- a/src/main/java/org/folio/notes/integration/kafka/NoteEventProducer.java +++ b/src/main/java/org/folio/notes/integration/kafka/NoteEventProducer.java @@ -3,6 +3,7 @@ import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; +import java.util.Optional; import java.util.UUID; import lombok.extern.slf4j.Slf4j; import org.apache.kafka.clients.producer.ProducerRecord; @@ -30,7 +31,8 @@ @Component public class NoteEventProducer { - private static final String EVENT_TYPE_HEADER = "domain-event-type"; + private static final String EVENT_TYPE_HEADER = "eventType"; + private static final String EVENT_DOMAIN_HEADER = "domain"; private final KafkaTemplate> kafkaTemplate; private final FolioExecutionContext context; @@ -65,6 +67,7 @@ public void publish(UUID id, DomainEvent event) { private List
buildHeaders(DomainEvent event) { var headers = new ArrayList
(); headers.add(header(EVENT_TYPE_HEADER, event.getType().name())); + headers.add(header(EVENT_DOMAIN_HEADER, extractDomain(event))); context.getAllHeaders().forEach((key, value) -> headers.add(header(key, value.iterator().next()))); return headers; } @@ -72,4 +75,11 @@ private List
buildHeaders(DomainEvent event) { private Header header(String key, String value) { return new RecordHeader(key, value == null ? null : value.getBytes(StandardCharsets.UTF_8)); } + + private String extractDomain(DomainEvent event) { + return Optional.ofNullable(event.getNewEntity()) + .or(() -> Optional.ofNullable(event.getOldEntity())) + .map(Note::getDomain) + .orElse(null); + } } diff --git a/src/test/java/org/folio/notes/service/NoteTenantServiceTest.java b/src/test/java/org/folio/notes/service/NoteTenantServiceTest.java index 4e9bab1c..d6fac314 100644 --- a/src/test/java/org/folio/notes/service/NoteTenantServiceTest.java +++ b/src/test/java/org/folio/notes/service/NoteTenantServiceTest.java @@ -7,9 +7,9 @@ import org.folio.spring.liquibase.FolioSpringLiquibase; import org.folio.spring.testing.type.UnitTest; import org.folio.spring.tools.kafka.KafkaAdminService; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.jdbc.core.JdbcTemplate; @@ -29,14 +29,9 @@ class NoteTenantServiceTest { @Mock private KafkaAdminService kafkaAdminService; + @InjectMocks private NoteTenantService tenantService; - @BeforeEach - void setUp() { - tenantService = new NoteTenantService(jdbcTemplate, context, folioSpringLiquibase, noteTypesService, - kafkaAdminService); - } - @Test void shouldPopulateDefaultTypeOnLoadReferenceData() { tenantService.loadReferenceData();