diff --git a/NEWS.md b/NEWS.md index 3b63003..1350d7a 100644 --- a/NEWS.md +++ b/NEWS.md @@ -7,7 +7,8 @@ * Requires `API_NAME vX.Y` ### Features -* Description ([ISSUE](https://folio-org.atlassian.net/browse/ISSUE)) +* Add Kafka producer foundation and publish Note create domain events ([MODNOTES-295](https://folio-org.atlassian.net/browse/MODNOTES-295)) +* Publish Note update and delete domain events to Kafka ([MODNOTES-296](https://folio-org.atlassian.net/browse/MODNOTES-296)) ### Bug fixes * Description ([ISSUE](https://folio-org.atlassian.net/browse/ISSUE)) @@ -16,9 +17,9 @@ * Description ([ISSUE](https://folio-org.atlassian.net/browse/ISSUE)) ### Dependencies -* Bump `LIB_NAME` from `OLD_VERSION` to `NEW_VERSION` -* Add `LIB_NAME VERSION` -* Remove `LIB_NAME` +* Add `folio-service-tools-spring-dev 6.0.0` +* Add `spring-boot-starter-kafka` +* Add `spring-boot-starter-kafka-test` ## v8.0.0 2026-04-15 ### Breaking changes 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 00c91e4..65291a3 100644 --- a/src/main/java/org/folio/notes/service/impl/NotesServiceImpl.java +++ b/src/main/java/org/folio/notes/service/impl/NotesServiceImpl.java @@ -173,7 +173,10 @@ public void updateNote(UUID id, Note dto) { noteRepository.findById(id) .ifPresentOrElse(entity -> { log.info("updateNote:: note loaded with id: {}", id); - saveNote(dto, noteMapFunction(dto, entity)); + Note storedNote = notesMapper.toDto(entity); + NoteEntity saved = saveNote(dto, noteMapFunction(dto, entity)); + Note note = notesMapper.toDto(saved); + domainEventPublisherService.publishNoteUpdatedEvent(storedNote, note); }, throwNotFoundById(id, "updateNote")); } } @@ -190,6 +193,8 @@ private void delete(UUID id) { .ifPresentOrElse(entity -> { noteRepository.deleteById(id); log.info("deleteNote:: deleted note with id: {}", id); + Note storedNote = notesMapper.toDto(entity); + domainEventPublisherService.publishNoteDeletedEvent(storedNote); }, throwNotFoundById(id, "deleteNote")); } diff --git a/src/test/java/org/folio/notes/controller/NotesControllerIT.java b/src/test/java/org/folio/notes/controller/NotesControllerIT.java index fdf6be0..4bb0d57 100644 --- a/src/test/java/org/folio/notes/controller/NotesControllerIT.java +++ b/src/test/java/org/folio/notes/controller/NotesControllerIT.java @@ -15,7 +15,6 @@ 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 +43,8 @@ import org.folio.notes.domain.dto.User; import org.folio.notes.domain.entity.NoteEntity; import org.folio.notes.domain.entity.NoteTypeEntity; +import org.folio.notes.domain.event.DomainEvent; +import org.folio.notes.domain.event.DomainEventType; import org.folio.notes.exception.NoteNotFoundException; import org.folio.notes.support.TestApiBase; import org.folio.notes.support.TestKafkaConsumer; @@ -67,7 +68,7 @@ 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; +import tools.jackson.core.type.TypeReference; @TestPropertySource(properties = {"folio.notes.types.defaults.limit=5"}) class NotesControllerIT extends TestApiBase { @@ -98,6 +99,7 @@ class NotesControllerIT extends TestApiBase { 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"; + private static final TypeReference> NOTE_EVENT_CLASS = new TypeReference<>() {}; @Value("${folio.notes.types.defaults.limit}") private String defaultNoteTypeLimit; @@ -1085,25 +1087,131 @@ 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") + .title("Kafka title").content("Kafka details").domain("orders") + .typeId(EVENT_NOTE_TYPE_ID).links(List.of(link)); + + var response = mockMvc.perform(postNote(note)) + .andExpect(status().isCreated()) + .andReturn().getResponse().getContentAsString(); + var newNoteId = OBJECT_MAPPER.readTree(response).get("id").asString(); + + var event = pollNoteEvent(newNoteId, DomainEventType.CREATE); + assertNotNull(event); + var eventNote = event.getNewEntity(); + assertNotNull(eventNote); + assertEquals("Kafka title", eventNote.getTitle()); + assertEquals("Kafka details", eventNote.getContent()); + assertEquals(EVENT_NOTE_TYPE_ID.toString(), eventNote.getTypeId().toString()); + assertEquals(USER_ID.toString(), eventNote.getMetadata().getCreatedByUserId().toString()); + var links = eventNote.getLinks(); + assertEquals(1, links.size()); + assertEquals(ORDER_LINE_TYPE, links.getFirst().getType()); + assertEquals(link.getId(), links.getFirst().getId()); + } + + @Test + @DisplayName("update publishes an UPDATE event carrying both old and new snapshots") + void updateNote_publishesUpdateEventWithOldAndNew() throws Exception { + saveEventNoteType(); + var link = new Link().id(UUID.randomUUID().toString()).type(ORDER_LINE_TYPE); + var created = createNote(new Note() + .title("A") + .content("Original") .domain("orders") .typeId(EVENT_NOTE_TYPE_ID) - .links(List.of(link)); + .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(); + created.title("B").content("Updated"); + mockMvc.perform(put(NOTE_URL + "/" + created.getId()).headers(okapiHeaders()).content(asJsonString(created))) + .andExpect(status().isNoContent()); - var event = consumer.poll(newNoteId); - assertNotNull(event); + var event = pollNoteEvent(created.getId().toString(), DomainEventType.UPDATE); + var oldNote = event.getOldEntity(); + var newNote = event.getNewEntity(); + assertNotNull(oldNote, "UPDATE event must carry an 'old' snapshot"); + assertNotNull(newNote, "UPDATE event must carry a 'new' snapshot"); + assertEquals(created.getId().toString(), oldNote.getId().toString()); + assertEquals(created.getId().toString(), newNote.getId().toString()); + assertEquals("A", oldNote.getTitle()); + assertEquals("Original", oldNote.getContent()); + assertEquals("B", newNote.getTitle()); + assertEquals("Updated", newNote.getContent()); + } - var envelope = OBJECT_MAPPER.readTree(event.value()); - assertEventEnvelope(envelope, event.value()); - assertEventPayload(envelope.get("new"), newNoteId, link); - } + @Test + @DisplayName("update preserves creation metadata across old and new snapshots") + void updateNote_preservesCreationMetadata() throws Exception { + saveEventNoteType(); + var link = new Link().id(UUID.randomUUID().toString()).type(ORDER_LINE_TYPE); + var created = createNote(new Note() + .title("A") + .content("Original") + .domain("orders") + .typeId(EVENT_NOTE_TYPE_ID) + .links(List.of(link))); + + created.content("Updated"); + mockMvc.perform(put(NOTE_URL + "/" + created.getId()).headers(okapiHeaders()).content(asJsonString(created))) + .andExpect(status().isNoContent()); + + var event = pollNoteEvent(created.getId().toString(), DomainEventType.UPDATE); + var oldMetadata = event.getOldEntity().getMetadata(); + var newMetadata = event.getNewEntity().getMetadata(); + + assertEquals(USER_ID, oldMetadata.getCreatedByUserId()); + assertEquals(oldMetadata.getCreatedByUserId(), newMetadata.getCreatedByUserId()); + assertEquals(oldMetadata.getCreatedDate(), newMetadata.getCreatedDate()); + } + + @Test + @DisplayName("update detects an order-line link change between old and new snapshots") + void updateNote_detectsOrderLineLinkChange() throws Exception { + saveEventNoteType(); + var originalLink = new Link().id(UUID.randomUUID().toString()).type(ORDER_LINE_TYPE); + var created = createNote(new Note() + .title("Linked note") + .content("Original") + .domain("orders") + .typeId(EVENT_NOTE_TYPE_ID) + .links(List.of(originalLink))); + + var replacementLink = new Link().id(UUID.randomUUID().toString()).type(ORDER_LINE_TYPE); + created.links(List.of(replacementLink)); + mockMvc.perform(put(NOTE_URL + "/" + created.getId()).headers(okapiHeaders()).content(asJsonString(created))) + .andExpect(status().isNoContent()); + + var event = pollNoteEvent(created.getId().toString(), DomainEventType.UPDATE); + var oldLinks = event.getOldEntity().getLinks(); + var newLinks = event.getNewEntity().getLinks(); + + assertEquals(1, oldLinks.size()); + assertEquals(1, newLinks.size()); + assertEquals(originalLink.getId(), oldLinks.getFirst().getId()); + assertEquals(replacementLink.getId(), newLinks.getFirst().getId()); + assertEquals(ORDER_LINE_TYPE, newLinks.getFirst().getType()); + } + + @Test + @DisplayName("delete publishes an DELETE event carrying old snapshot") + void deleteNote_publishesDeleteEvent() throws Exception { + saveEventNoteType(); + var link = new Link().id(UUID.randomUUID().toString()).type(ORDER_LINE_TYPE); + var created = createNote(new Note() + .title("A") + .content("Original") + .domain("orders") + .typeId(EVENT_NOTE_TYPE_ID) + .links(List.of(link))); + + mockMvc.perform(delete(NOTE_URL + "/" + created.getId()).headers(okapiHeaders()).content(asJsonString(created))) + .andExpect(status().isNoContent()); + + var event = pollNoteEvent(created.getId().toString(), DomainEventType.DELETE); + var oldNote = event.getOldEntity(); + assertNotNull(oldNote, "DELETE event must carry an 'old' snapshot"); + assertEquals(created.getId().toString(), oldNote.getId().toString()); + assertEquals("A", oldNote.getTitle()); + assertEquals("Original", oldNote.getContent()); } private void saveEventNoteType() { @@ -1114,26 +1222,14 @@ private void saveEventNoteType() { 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 DomainEvent pollNoteEvent(String noteId, DomainEventType type) { + try (var consumer = TestKafkaConsumer.subscribe(NOTE_EVENT_TOPIC, kafkaProperties)) { + return consumer.poll(noteId).stream() + .map(event -> OBJECT_MAPPER.readValue(event.value(), NOTE_EVENT_CLASS)) + .filter(event -> event.getType() == type) + .findFirst() + .orElseThrow(() -> new AssertionError("Expected event of type " + type + " for noteId " + noteId)); + } } private Note generateNote() throws Exception { @@ -1220,6 +1316,13 @@ private NoteEntity createNote() { return noteEntity; } + private Note createNote(Note note) throws Exception { + var response = mockMvc.perform(post(NOTE_URL).headers(okapiHeaders()).content(asJsonString(note))) + .andExpect(status().isCreated()) + .andReturn().getResponse().getContentAsString(); + return OBJECT_MAPPER.readValue(response, Note.class); + } + private NoteEntity prepareNote(int noteNum) { var noteEntity = new NoteEntity(); noteEntity.setId(NOTE_IDS[noteNum]); diff --git a/src/test/java/org/folio/notes/support/TestKafkaConsumer.java b/src/test/java/org/folio/notes/support/TestKafkaConsumer.java index 5b47d49..b7fda17 100644 --- a/src/test/java/org/folio/notes/support/TestKafkaConsumer.java +++ b/src/test/java/org/folio/notes/support/TestKafkaConsumer.java @@ -96,10 +96,10 @@ private static void createTopic(String topic, KafkaProperties properties) { * * @return the received record */ - public ConsumerRecord poll(String key) { + public List> poll(String key) { return poll(DEFAULT_POLL_TIMEOUT).stream() - .filter(e -> Objects.equals(e.key(), key)).findFirst() - .orElse(null); + .filter(e -> Objects.equals(e.key(), key)) + .toList(); } /**