From ba3775f42b9270ce5897eb1f667e7cbd955a72b1 Mon Sep 17 00:00:00 2001 From: Steven Liu Date: Thu, 27 Nov 2025 14:47:38 +0100 Subject: [PATCH 1/7] feat: add WS dependency --- server/pom.xml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/server/pom.xml b/server/pom.xml index e4e9e6a..bd9a370 100644 --- a/server/pom.xml +++ b/server/pom.xml @@ -26,6 +26,10 @@ org.springframework.boot spring-boot-starter-data-jpa + + org.springframework.boot + spring-boot-starter-websocket + com.h2database h2 @@ -43,6 +47,7 @@ spring-boot-starter-test test + From dcb76612d5a774ea3c28ad049888c332886d59b8 Mon Sep 17 00:00:00 2001 From: Steven Liu Date: Thu, 27 Nov 2025 14:51:38 +0100 Subject: [PATCH 2/7] feat: websocket config endpoints --- .../src/main/java/server/WebSocketConfig.java | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 server/src/main/java/server/WebSocketConfig.java diff --git a/server/src/main/java/server/WebSocketConfig.java b/server/src/main/java/server/WebSocketConfig.java new file mode 100644 index 0000000..96d40d5 --- /dev/null +++ b/server/src/main/java/server/WebSocketConfig.java @@ -0,0 +1,33 @@ +package server; + +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +@Configuration +@EnableWebSocketMessageBroker +public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + @Override + public void configureMessageBroker(MessageBrokerRegistry registry) { + + // SUBSCRIBE /subscribe/{mapping}, see UpdateMessagingController definition + // adds a broker endpoint subscribers listen to + registry.enableSimpleBroker("/subscribe"); + + // SEND /send/updates/{mapping}, see UpdateMessagingController definition + // the client pushes a new message to this address + registry.setApplicationDestinationPrefixes("/send"); + } + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + // a client can now push data to /send/updates/{mapping}, see UpdateMessagingController + // allow access from any origin: one server, many clients + registry.addEndpoint("/updates").setAllowedOrigins("*"); + // optional, recommended to increase browser & networking support + registry.addEndpoint("/updates").setAllowedOrigins("*").withSockJS(); + + } +} From fcc7cfd9ac0329dfcfaeb2d1d718f28a72f10438 Mon Sep 17 00:00:00 2001 From: Steven Liu Date: Thu, 27 Nov 2025 14:58:56 +0100 Subject: [PATCH 3/7] feat: message broadcast endpoint mapping --- .../server/api/UpdateMessagingController.java | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 server/src/main/java/server/api/UpdateMessagingController.java diff --git a/server/src/main/java/server/api/UpdateMessagingController.java b/server/src/main/java/server/api/UpdateMessagingController.java new file mode 100644 index 0000000..d4b01f2 --- /dev/null +++ b/server/src/main/java/server/api/UpdateMessagingController.java @@ -0,0 +1,30 @@ +package server.api; + +import commons.Recipe; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.handler.annotation.SendTo; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Controller; + +@Controller +public class UpdateMessagingController { + private final RecipeController recipeController; + @Autowired + public UpdateMessagingController( + RecipeController recipeController) { + this.recipeController = recipeController; + } + + /** + * Mapping for STOMP: SEND /updates/recipe + * @param recipe The request body as a new {@link Recipe} object to update the original into + * @return The updated {@link Recipe} object wrapped in a {@link org.springframework.http.ResponseEntity}. + */ + @MessageMapping("/updates/recipe") + @SendTo("/subscribe/recipe") + public ResponseEntity broadcastRecipeUpdate(Recipe recipe) { + return recipeController.updateRecipe(recipe.getId(), recipe); + } +} From ee16265cb8f9667f10894a4cacdcfba994591066 Mon Sep 17 00:00:00 2001 From: Steven Liu Date: Thu, 27 Nov 2025 14:59:46 +0100 Subject: [PATCH 4/7] feat: message broadcast endpoint mapping --- server/src/main/java/server/api/UpdateMessagingController.java | 1 - 1 file changed, 1 deletion(-) diff --git a/server/src/main/java/server/api/UpdateMessagingController.java b/server/src/main/java/server/api/UpdateMessagingController.java index d4b01f2..a78d0fb 100644 --- a/server/src/main/java/server/api/UpdateMessagingController.java +++ b/server/src/main/java/server/api/UpdateMessagingController.java @@ -5,7 +5,6 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.messaging.handler.annotation.MessageMapping; import org.springframework.messaging.handler.annotation.SendTo; -import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.stereotype.Controller; @Controller From 8e24e47813886133f623927c303c5de0cffe0fbe Mon Sep 17 00:00:00 2001 From: Natalia Cholewa Date: Thu, 4 Dec 2025 22:52:14 +0100 Subject: [PATCH 5/7] feat: topics, recipe messages --- commons/src/main/java/commons/ws/Topics.java | 5 +++ .../ws/messages/CreateRecipeMessage.java | 30 +++++++++++++ .../ws/messages/DeleteRecipeMessage.java | 28 ++++++++++++ .../java/commons/ws/messages/Message.java | 44 +++++++++++++++++++ .../ws/messages/UpdateRecipeMessage.java | 30 +++++++++++++ .../java/server/api/RecipeController.java | 23 +++++++--- .../java/server/api/RecipeControllerTest.java | 7 ++- 7 files changed, 160 insertions(+), 7 deletions(-) create mode 100644 commons/src/main/java/commons/ws/Topics.java create mode 100644 commons/src/main/java/commons/ws/messages/CreateRecipeMessage.java create mode 100644 commons/src/main/java/commons/ws/messages/DeleteRecipeMessage.java create mode 100644 commons/src/main/java/commons/ws/messages/Message.java create mode 100644 commons/src/main/java/commons/ws/messages/UpdateRecipeMessage.java diff --git a/commons/src/main/java/commons/ws/Topics.java b/commons/src/main/java/commons/ws/Topics.java new file mode 100644 index 0000000..9eb8d9f --- /dev/null +++ b/commons/src/main/java/commons/ws/Topics.java @@ -0,0 +1,5 @@ +package commons.ws; + +public class Topics { + public static final String RECIPES = "/subscribe/recipe"; +} diff --git a/commons/src/main/java/commons/ws/messages/CreateRecipeMessage.java b/commons/src/main/java/commons/ws/messages/CreateRecipeMessage.java new file mode 100644 index 0000000..b036c1f --- /dev/null +++ b/commons/src/main/java/commons/ws/messages/CreateRecipeMessage.java @@ -0,0 +1,30 @@ +package commons.ws.messages; + +import commons.Recipe; + +/** + * Message sent when a new recipe is created. + * + * @see commons.ws.messages.Message.Type#RECIPE_CREATE + */ +public class CreateRecipeMessage implements Message { + private Recipe recipe; + + public CreateRecipeMessage(Recipe recipe) { + this.recipe = recipe; + } + + @Override + public Type getType() { + return Type.RECIPE_CREATE; + } + + /** + * Get the created recipe. + * + * @return The created recipe. + */ + public Recipe getRecipe() { + return recipe; + } +} diff --git a/commons/src/main/java/commons/ws/messages/DeleteRecipeMessage.java b/commons/src/main/java/commons/ws/messages/DeleteRecipeMessage.java new file mode 100644 index 0000000..1802525 --- /dev/null +++ b/commons/src/main/java/commons/ws/messages/DeleteRecipeMessage.java @@ -0,0 +1,28 @@ +package commons.ws.messages; + +/** + * Message sent when a recipe is deleted. + * + * @see commons.ws.messages.Message.Type#RECIPE_DELETE + */ +public class DeleteRecipeMessage implements Message { + private Long recipeId; + + public DeleteRecipeMessage(Long recipeId) { + this.recipeId = recipeId; + } + + @Override + public Type getType() { + return Type.RECIPE_DELETE; + } + + /** + * Get the ID of the deleted recipe. + * + * @return The ID of the deleted recipe. + */ + public Long getRecipeId() { + return recipeId; + } +} diff --git a/commons/src/main/java/commons/ws/messages/Message.java b/commons/src/main/java/commons/ws/messages/Message.java new file mode 100644 index 0000000..681474f --- /dev/null +++ b/commons/src/main/java/commons/ws/messages/Message.java @@ -0,0 +1,44 @@ +package commons.ws.messages; + +public interface Message { + public enum Type { + /** + * Message sent when a new recipe is created. + * + * @see commons.ws.messages.CreateRecipeMessage + */ + RECIPE_CREATE, + + /** + * Message sent when an existing recipe is updated. + * + * @see commons.ws.messages.UpdateRecipeMessage + */ + RECIPE_UPDATE, + + /** + * Message sent when a recipe is deleted. + * + * @see commons.ws.messages.DeleteRecipeMessage + */ + RECIPE_DELETE + } + + /** + * Get the type of the message. + * This can be used to match the message to the appropriate handler. + * + *

Example

+ *
+     * Message msg = ...;
+     * switch (msg.getType()) {
+     *     case RECIPE_CREATE -> handleCreate((CreateRecipeMessage) msg);
+     *     case RECIPE_UPDATE -> handleUpdate((UpdateRecipeMessage) msg);
+     *     default -> { /* handle other cases *\/ }
+     * }
+     * 
+ * + * @return The type of the message. + */ + public Type getType(); +} diff --git a/commons/src/main/java/commons/ws/messages/UpdateRecipeMessage.java b/commons/src/main/java/commons/ws/messages/UpdateRecipeMessage.java new file mode 100644 index 0000000..c623b75 --- /dev/null +++ b/commons/src/main/java/commons/ws/messages/UpdateRecipeMessage.java @@ -0,0 +1,30 @@ +package commons.ws.messages; + +import commons.Recipe; + +/** + * Message sent when an existing recipe is updated. + * + * @see commons.ws.messages.Message.Type#RECIPE_UPDATE + */ +public class UpdateRecipeMessage implements Message { + private Recipe recipe; + + public UpdateRecipeMessage(Recipe recipe) { + this.recipe = recipe; + } + + @Override + public Type getType() { + return Type.RECIPE_UPDATE; + } + + /** + * Get the updated recipe. + * + * @return The updated recipe. + */ + public Recipe getRecipe() { + return recipe; + } +} diff --git a/server/src/main/java/server/api/RecipeController.java b/server/src/main/java/server/api/RecipeController.java index a1d79fe..989f9f0 100644 --- a/server/src/main/java/server/api/RecipeController.java +++ b/server/src/main/java/server/api/RecipeController.java @@ -2,10 +2,15 @@ package server.api; import commons.Recipe; +import commons.ws.Topics; +import commons.ws.messages.CreateRecipeMessage; +import commons.ws.messages.DeleteRecipeMessage; +import commons.ws.messages.UpdateRecipeMessage; import org.springframework.data.domain.Example; import org.springframework.data.domain.PageRequest; import org.springframework.http.ResponseEntity; +import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -25,9 +30,11 @@ import java.util.Optional; @RequestMapping("/api") public class RecipeController { private final RecipeRepository recipeRepository; // JPA repository used in this controller + private final SimpMessagingTemplate messagingTemplate; - public RecipeController(RecipeRepository recipeRepository) { + public RecipeController(RecipeRepository recipeRepository, SimpMessagingTemplate messagingTemplate) { this.recipeRepository = recipeRepository; + this.messagingTemplate = messagingTemplate; } /** @@ -61,6 +68,7 @@ public class RecipeController { PageRequest.of(0, limit.get()) ).toList()); } + return ResponseEntity.ok(recipeRepository.findAll()); } @@ -76,9 +84,10 @@ public class RecipeController { return ResponseEntity.badRequest().build(); } - // TODO: Send WS update to all subscribers with the updated recipe + Recipe saved = recipeRepository.save(recipe); + messagingTemplate.convertAndSend(Topics.RECIPES, new UpdateRecipeMessage(saved)); - return ResponseEntity.ok(recipeRepository.save(recipe)); + return ResponseEntity.ok(saved); } /** @@ -104,9 +113,10 @@ public class RecipeController { return ResponseEntity.badRequest().build(); } - // TODO: Send WS update to all subscribers with the new recipe + Recipe saved = recipeRepository.save(recipe); + messagingTemplate.convertAndSend(Topics.RECIPES, new CreateRecipeMessage(saved)); - return ResponseEntity.ok(recipeRepository.save(recipe)); + return ResponseEntity.ok(saved); } /** @@ -124,7 +134,8 @@ public class RecipeController { } recipeRepository.deleteById(id); - // TODO: Send WS update to propagate deletion + messagingTemplate.convertAndSend(Topics.RECIPES, new DeleteRecipeMessage(id)); + return ResponseEntity.ok(true); } } diff --git a/server/src/test/java/server/api/RecipeControllerTest.java b/server/src/test/java/server/api/RecipeControllerTest.java index f1a766e..5b76a9f 100644 --- a/server/src/test/java/server/api/RecipeControllerTest.java +++ b/server/src/test/java/server/api/RecipeControllerTest.java @@ -9,6 +9,7 @@ import org.junit.jupiter.api.TestInfo; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.http.HttpStatus; +import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.test.context.ActiveProfiles; import server.database.RecipeRepository; @@ -30,6 +31,10 @@ import static org.junit.jupiter.api.Assertions.assertEquals; // This config uses an in-memory database @ActiveProfiles("mock-data-test") public class RecipeControllerTest { + + @Autowired + private SimpMessagingTemplate template; + private RecipeController controller; private List recipes; private final RecipeRepository recipeRepository; @@ -48,7 +53,7 @@ public class RecipeControllerTest { .range(0, NUM_RECIPES) .mapToObj(x -> new Recipe(null, "Recipe " + x, List.of(), List.of())) .toList(); - controller = new RecipeController(recipeRepository); + controller = new RecipeController(recipeRepository, template); Set tags = info.getTags(); List ids = new ArrayList<>(); From 25fdbcf49e7ea1ca9663f73f5b681c0b77c0a1d0 Mon Sep 17 00:00:00 2001 From: Natalia Cholewa Date: Thu, 4 Dec 2025 23:03:52 +0100 Subject: [PATCH 6/7] fix: testing with websocket connection --- .../java/server/api/RecipeControllerTest.java | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/server/src/test/java/server/api/RecipeControllerTest.java b/server/src/test/java/server/api/RecipeControllerTest.java index 5b76a9f..f09d4ff 100644 --- a/server/src/test/java/server/api/RecipeControllerTest.java +++ b/server/src/test/java/server/api/RecipeControllerTest.java @@ -8,9 +8,11 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestInfo; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; import org.springframework.http.HttpStatus; import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.test.context.ActiveProfiles; +import server.WebSocketConfig; import server.database.RecipeRepository; import java.util.ArrayList; @@ -20,6 +22,7 @@ import java.util.Set; import java.util.stream.LongStream; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; // Spring Boot unit testing magic // Before each test the state of the repository is reset by @@ -30,11 +33,14 @@ import static org.junit.jupiter.api.Assertions.assertEquals; // resources/application-mock-data-test.properties // This config uses an in-memory database @ActiveProfiles("mock-data-test") + +// This is required to enable WebSocket messaging in tests +// +// Without this line, Spring screams about missing SimpMessagingTemplate bean +@Import(WebSocketConfig.class) public class RecipeControllerTest { - @Autowired - private SimpMessagingTemplate template; - + private final SimpMessagingTemplate template; private RecipeController controller; private List recipes; private final RecipeRepository recipeRepository; @@ -43,8 +49,9 @@ public class RecipeControllerTest { // Injects a test repository into the test class @Autowired - public RecipeControllerTest(RecipeRepository recipeRepository) { + public RecipeControllerTest(RecipeRepository recipeRepository, SimpMessagingTemplate template) { this.recipeRepository = recipeRepository; + this.template = template; } @BeforeEach @@ -72,6 +79,7 @@ public class RecipeControllerTest { // If no tags specified, the repository is initialized as empty. } + @Test public void createOneRecipe() { controller.createRecipe(recipes.getFirst()); @@ -92,6 +100,7 @@ public class RecipeControllerTest { // The number of recipes returned is the same as the entire input list assertEquals(recipes.size(), controller.getRecipes(Optional.empty()).getBody().size()); } + @Test @Tag("test-from-init-data") public void getSomeRecipes() { @@ -99,6 +108,7 @@ public class RecipeControllerTest { // The number of recipes returned is the same as the entire input list assertEquals(LIMIT, controller.getRecipes(Optional.of(LIMIT)).getBody().size()); } + @Test @Tag("test-from-init-data") @Tag("need-ids") @@ -109,6 +119,7 @@ public class RecipeControllerTest { recipes.get(CHECK_INDEX), controller.getRecipe(recipeIds.get(CHECK_INDEX)).getBody()); } + @Test public void findOneRecipeNotExists() { final int CHECK_INDEX = 3; @@ -117,6 +128,7 @@ public class RecipeControllerTest { HttpStatus.NOT_FOUND, controller.getRecipe((long) CHECK_INDEX).getStatusCode()); } + @Test @Tag("test-from-init-data") @Tag("need-ids") @@ -126,15 +138,18 @@ public class RecipeControllerTest { // The object has been successfully deleted assertEquals(HttpStatus.OK, controller.deleteRecipe(recipeIds.get(DELETE_INDEX)).getStatusCode()); } + @Test @Tag("test-from-init-data") @Tag("need-ids") public void deleteOneRecipeCountGood() { final int DELETE_INDEX = 5; controller.deleteRecipe(recipeIds.get(DELETE_INDEX)); + // The count of items decreased by 1 after the 5th item has been removed. assertEquals(recipeIds.size() - 1, recipeRepository.count()); } + @Test public void deleteOneRecipeFail() { final Long DELETE_INDEX = 5L; From e85a43725dd890d0da347895a2590025c1fe3839 Mon Sep 17 00:00:00 2001 From: Natalia Cholewa Date: Thu, 4 Dec 2025 23:08:10 +0100 Subject: [PATCH 7/7] fix: checkstyle violations --- server/src/test/java/server/api/RecipeControllerTest.java | 1 - 1 file changed, 1 deletion(-) diff --git a/server/src/test/java/server/api/RecipeControllerTest.java b/server/src/test/java/server/api/RecipeControllerTest.java index f09d4ff..9b27d2c 100644 --- a/server/src/test/java/server/api/RecipeControllerTest.java +++ b/server/src/test/java/server/api/RecipeControllerTest.java @@ -22,7 +22,6 @@ import java.util.Set; import java.util.stream.LongStream; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; // Spring Boot unit testing magic // Before each test the state of the repository is reset by