From 9076d0d80f45bf75e737b030016a3144080e4e3e Mon Sep 17 00:00:00 2001 From: Ralf Ueberfuhr Date: Fri, 13 Feb 2026 09:36:57 +0100 Subject: [PATCH] add event logging for recipe creation with `RecipeEventsLogger`, implement domain events and tests --- .../recipes/domain/RecipesService.java | 6 + .../domain/events/RecipeCreatedEvent.java | 8 + .../recipes/domain/events/RecipeEvent.java | 4 + .../infrastructure/RecipeEventsLogger.java | 25 ++++ .../RecipeEventsLoggerTests.java | 52 +++++++ .../quarkus/recipes/test/CaptureOutput.java | 141 ++++++++++++++++++ 6 files changed, 236 insertions(+) create mode 100644 src/main/java/de/schulung/quarkus/recipes/domain/events/RecipeCreatedEvent.java create mode 100644 src/main/java/de/schulung/quarkus/recipes/domain/events/RecipeEvent.java create mode 100644 src/main/java/de/schulung/quarkus/recipes/infrastructure/RecipeEventsLogger.java create mode 100644 src/test/java/de/schulung/quarkus/recipes/infrastructure/RecipeEventsLoggerTests.java create mode 100644 src/test/java/de/schulung/quarkus/recipes/test/CaptureOutput.java diff --git a/src/main/java/de/schulung/quarkus/recipes/domain/RecipesService.java b/src/main/java/de/schulung/quarkus/recipes/domain/RecipesService.java index ec982c8..c274869 100644 --- a/src/main/java/de/schulung/quarkus/recipes/domain/RecipesService.java +++ b/src/main/java/de/schulung/quarkus/recipes/domain/RecipesService.java @@ -1,8 +1,11 @@ package de.schulung.quarkus.recipes.domain; +import de.schulung.quarkus.recipes.domain.events.RecipeCreatedEvent; +import de.schulung.quarkus.recipes.domain.events.RecipeEvent; import de.schulung.quarkus.recipes.domain.model.Difficulty; import de.schulung.quarkus.recipes.domain.model.Recipe; import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Event; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -15,6 +18,7 @@ public class RecipesService { private final RecipesDao dao; + private final Event eventPublisher; public Stream findAll() { return dao.findAll(); @@ -39,5 +43,7 @@ public void create( } dao.save(recipe); + + eventPublisher.fire(new RecipeCreatedEvent(recipe)); } } diff --git a/src/main/java/de/schulung/quarkus/recipes/domain/events/RecipeCreatedEvent.java b/src/main/java/de/schulung/quarkus/recipes/domain/events/RecipeCreatedEvent.java new file mode 100644 index 0000000..978de71 --- /dev/null +++ b/src/main/java/de/schulung/quarkus/recipes/domain/events/RecipeCreatedEvent.java @@ -0,0 +1,8 @@ +package de.schulung.quarkus.recipes.domain.events; + +import de.schulung.quarkus.recipes.domain.model.Recipe; + +public record RecipeCreatedEvent( + Recipe recipe +) implements RecipeEvent { +} diff --git a/src/main/java/de/schulung/quarkus/recipes/domain/events/RecipeEvent.java b/src/main/java/de/schulung/quarkus/recipes/domain/events/RecipeEvent.java new file mode 100644 index 0000000..7cc834e --- /dev/null +++ b/src/main/java/de/schulung/quarkus/recipes/domain/events/RecipeEvent.java @@ -0,0 +1,4 @@ +package de.schulung.quarkus.recipes.domain.events; + +public interface RecipeEvent { +} diff --git a/src/main/java/de/schulung/quarkus/recipes/infrastructure/RecipeEventsLogger.java b/src/main/java/de/schulung/quarkus/recipes/infrastructure/RecipeEventsLogger.java new file mode 100644 index 0000000..38f05e4 --- /dev/null +++ b/src/main/java/de/schulung/quarkus/recipes/infrastructure/RecipeEventsLogger.java @@ -0,0 +1,25 @@ +package de.schulung.quarkus.recipes.infrastructure; + +import de.schulung.quarkus.recipes.domain.events.RecipeCreatedEvent; +import io.quarkus.arc.log.LoggerName; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; +import org.jboss.logging.Logger; + +@ApplicationScoped +public class RecipeEventsLogger { + + @LoggerName("customer-events") + Logger log; + + public void logRecipeCreatedEvent( + @Observes + RecipeCreatedEvent event + ) { + log.infov( + "Recipe created with id={0}", + event.recipe().getId() + ); + } + +} diff --git a/src/test/java/de/schulung/quarkus/recipes/infrastructure/RecipeEventsLoggerTests.java b/src/test/java/de/schulung/quarkus/recipes/infrastructure/RecipeEventsLoggerTests.java new file mode 100644 index 0000000..5a73fac --- /dev/null +++ b/src/test/java/de/schulung/quarkus/recipes/infrastructure/RecipeEventsLoggerTests.java @@ -0,0 +1,52 @@ +package de.schulung.quarkus.recipes.infrastructure; + +import de.schulung.quarkus.recipes.test.CaptureOutput; +import de.schulung.quarkus.recipes.test.CaptureOutput.CapturedOutput; +import io.quarkus.test.TestTransaction; +import io.quarkus.test.junit.QuarkusTest; +import io.restassured.http.ContentType; +import org.junit.jupiter.api.Test; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@QuarkusTest +@TestTransaction +@CaptureOutput +class RecipeEventsLoggerTests { + + @Test + void whenCreateRecipe_thenLogEvent(CapturedOutput output) { + var response = given() + .contentType(ContentType.JSON) + .body(""" + { + "name": "Tomato Soup", + "img": "/my-image", + "servings": 4, + "duration": 30, + "difficulty": "easy", + "ingredients": [ + { "unit": "pieces", "quantity": 2.0, "name": "Tomato" } + ], + "preparation": "Boil tomatoes." + } + """) + .accept(ContentType.JSON) + .when() + .post("/recipes"); + response + .then() + .statusCode(201) + .header("Location", is(notNullValue())); + var id = response.body().jsonPath().getString("id"); + + assertTrue( + output.toString().matches("(?si).*Recipe created.*" + id + ".*"), + "Expected log output to contain 'Recipe created' with id=" + id + ); + } + +} diff --git a/src/test/java/de/schulung/quarkus/recipes/test/CaptureOutput.java b/src/test/java/de/schulung/quarkus/recipes/test/CaptureOutput.java new file mode 100644 index 0000000..55c6a80 --- /dev/null +++ b/src/test/java/de/schulung/quarkus/recipes/test/CaptureOutput.java @@ -0,0 +1,141 @@ +package de.schulung.quarkus.recipes.test; + +import org.jspecify.annotations.NonNull; +import org.junit.jupiter.api.extension.*; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.text.MessageFormat; +import java.util.function.Supplier; +import java.util.logging.Handler; +import java.util.logging.LogRecord; +import java.util.logging.Logger; + +/** + * Annotation to enable capturing of output produced during test execution. + *

+ * When applied to a test method or class, any output (such as log messages) produced + * during the test's execution is captured, allowing it to be accessed and verified. + *

+ * This annotation works in conjunction with the {@link OutputCaptureExtension}, which + * handles the logic for capturing and providing access to the captured output. + *

+ * The annotation can be applied at the test method or class level. + *

+ * Target: {@link ElementType#METHOD}, {@link ElementType#TYPE} + * Retention: {@link RetentionPolicy#RUNTIME} + * + * @see OutputCaptureExtension + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ + ElementType.METHOD, + ElementType.TYPE +}) +@ExtendWith(CaptureOutput.OutputCaptureExtension.class) +public @interface CaptureOutput { + + @SuppressWarnings("NullableProblems") + public record CapturedOutput(Supplier output) + implements CharSequence { + + @Override + public int length() { + return output.get().length(); + } + + @Override + public char charAt(int index) { + return output.get().charAt(index); + } + + @Override + public CharSequence subSequence(int start, int end) { + return output.get().subSequence(start, end); + } + + @Override + public String toString() { + return output.get(); + } + } + + class OutputCaptureExtension + implements BeforeEachCallback, AfterEachCallback, ParameterResolver { + + private Handler handler; + private CapturedOutput capturedOutput; + + @Override + public void beforeEach( + @NonNull + ExtensionContext context + ) { + + final StringBuilder sb = new StringBuilder(); + this.handler = new Handler() { + + @Override + public void publish(LogRecord record) { + final var message = MessageFormat + .format( + record.getMessage(), + record.getParameters() + ); + sb.append(message); + } + + @Override + public void flush() { + } + + @Override + public void close() throws SecurityException { + } + }; + this.capturedOutput = new CapturedOutput(sb::toString); + + Logger + .getLogger("") + .addHandler(this.handler); + } + + @Override + public void afterEach( + @NonNull + ExtensionContext context + ) { + Logger + .getLogger("") + .removeHandler(this.handler); + this.handler = null; + this.capturedOutput = null; + } + + @Override + public boolean supportsParameter( + ParameterContext parameterContext, + @NonNull + ExtensionContext context + ) { + return parameterContext + .getParameter() + .getType() + .equals(CapturedOutput.class); + } + + @Override + public Object resolveParameter( + @NonNull + ParameterContext parameterContext, + @NonNull + ExtensionContext context + ) { + return this.capturedOutput; + } + + } + +}